controlzero 1.0.0__py3-none-any.whl

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 (41) hide show
  1. controlzero/__init__.py +41 -0
  2. controlzero/_internal/__init__.py +1 -0
  3. controlzero/_internal/dlp_scanner.py +777 -0
  4. controlzero/_internal/enforcer.py +210 -0
  5. controlzero/_internal/types.py +19 -0
  6. controlzero/audit_local.py +128 -0
  7. controlzero/audit_remote.py +221 -0
  8. controlzero/cli/__init__.py +1 -0
  9. controlzero/cli/main.py +1177 -0
  10. controlzero/cli/templates/autogen.yaml +79 -0
  11. controlzero/cli/templates/claude-code.yaml +85 -0
  12. controlzero/cli/templates/codex-cli.yaml +80 -0
  13. controlzero/cli/templates/cost-cap.yaml +64 -0
  14. controlzero/cli/templates/crewai.yaml +83 -0
  15. controlzero/cli/templates/cursor.yaml +86 -0
  16. controlzero/cli/templates/gemini-cli.yaml +85 -0
  17. controlzero/cli/templates/generic.yaml +57 -0
  18. controlzero/cli/templates/langchain.yaml +89 -0
  19. controlzero/cli/templates/mcp.yaml +79 -0
  20. controlzero/cli/templates/rag.yaml +63 -0
  21. controlzero/client.py +398 -0
  22. controlzero/enrollment.py +493 -0
  23. controlzero/errors.py +60 -0
  24. controlzero/policy_loader.py +245 -0
  25. controlzero/tamper.py +337 -0
  26. controlzero-1.0.0.data/data/controlzero/cli/templates/autogen.yaml +79 -0
  27. controlzero-1.0.0.data/data/controlzero/cli/templates/claude-code.yaml +85 -0
  28. controlzero-1.0.0.data/data/controlzero/cli/templates/codex-cli.yaml +80 -0
  29. controlzero-1.0.0.data/data/controlzero/cli/templates/cost-cap.yaml +64 -0
  30. controlzero-1.0.0.data/data/controlzero/cli/templates/crewai.yaml +83 -0
  31. controlzero-1.0.0.data/data/controlzero/cli/templates/cursor.yaml +86 -0
  32. controlzero-1.0.0.data/data/controlzero/cli/templates/gemini-cli.yaml +85 -0
  33. controlzero-1.0.0.data/data/controlzero/cli/templates/generic.yaml +57 -0
  34. controlzero-1.0.0.data/data/controlzero/cli/templates/langchain.yaml +89 -0
  35. controlzero-1.0.0.data/data/controlzero/cli/templates/mcp.yaml +79 -0
  36. controlzero-1.0.0.data/data/controlzero/cli/templates/rag.yaml +63 -0
  37. controlzero-1.0.0.dist-info/METADATA +232 -0
  38. controlzero-1.0.0.dist-info/RECORD +41 -0
  39. controlzero-1.0.0.dist-info/WHEEL +4 -0
  40. controlzero-1.0.0.dist-info/entry_points.txt +2 -0
  41. controlzero-1.0.0.dist-info/licenses/LICENSE +17 -0
@@ -0,0 +1,89 @@
1
+ # controlzero policy: LangChain / LangGraph template
2
+ # Schema version 1
3
+ #
4
+ # This template is opinionated for LangChain and LangGraph agents. The
5
+ # assumption: your agent is a chain of Tools, and you want each Tool call
6
+ # gated by Control Zero before it executes.
7
+ #
8
+ # Wire it into code:
9
+ #
10
+ # from controlzero import Client
11
+ # from langchain_core.tools import tool
12
+ #
13
+ # cz = Client(policy_file="./controlzero.yaml")
14
+ #
15
+ # @tool
16
+ # def delete_user(user_id: str) -> str:
17
+ # cz.guard("delete_user", {"user_id": user_id})
18
+ # # ... your delete logic ...
19
+ #
20
+ # The guard() call raises PolicyDeniedError if the policy denies. Your
21
+ # LangGraph state machine can catch it and route to a human-in-the-loop
22
+ # node, or just propagate it as a tool error back to the model.
23
+ #
24
+ # What this template blocks out of the box:
25
+ # - Destructive DB tools (drop, truncate, delete_all)
26
+ # - Filesystem writes to sensitive paths
27
+ # - Outbound network to arbitrary hosts
28
+ #
29
+ # What it allows:
30
+ # - Read-heavy tools (search, lookup, fetch_*)
31
+ # - Scoped writes (insert, update, append)
32
+ #
33
+ # Customize the allow/deny lists at the bottom for your own tool names.
34
+
35
+ version: '1'
36
+
37
+ rules:
38
+ # ============================================================
39
+ # DENY: destructive DB operations
40
+ # ============================================================
41
+ - id: deny-db-drop
42
+ deny: 'db_drop_*'
43
+ reason: 'Dropping database objects requires manual approval'
44
+
45
+ - id: deny-db-truncate
46
+ deny: 'db_truncate_*'
47
+ reason: 'Truncating tables is destructive; requires manual approval'
48
+
49
+ - id: deny-bulk-delete
50
+ deny: 'delete_all_*'
51
+ reason: 'Bulk deletes require manual approval'
52
+
53
+ # ============================================================
54
+ # DENY: filesystem writes to sensitive paths
55
+ # ============================================================
56
+ # v0.1 can only match on tool name, not arguments. This denies
57
+ # any Tool named *_etc_* or *_ssh_* as a defensive first pass.
58
+ - id: deny-fs-etc
59
+ deny: '*_etc_*'
60
+ reason: 'Writes to /etc are blocked'
61
+
62
+ - id: deny-fs-ssh
63
+ deny: '*_ssh_*'
64
+ reason: 'SSH config writes are blocked'
65
+
66
+ # ============================================================
67
+ # ALLOW: common LangChain tool names
68
+ # ============================================================
69
+ - id: allow-search
70
+ allow: 'search*'
71
+ reason: 'Search tools are safe by default'
72
+
73
+ - id: allow-fetch
74
+ allow: 'fetch_*'
75
+ reason: 'Fetch tools are safe by default'
76
+
77
+ - id: allow-lookup
78
+ allow: 'lookup_*'
79
+ reason: 'Lookup tools are safe by default'
80
+
81
+ - id: allow-list
82
+ allow: 'list_*'
83
+ reason: 'List tools are safe by default'
84
+
85
+ # ============================================================
86
+ # Default: deny anything not explicitly allowed (fail-closed)
87
+ # ============================================================
88
+ # No catch-all allow at the bottom. The v0.1 evaluator already
89
+ # fails closed, so anything that falls through lands in deny.
@@ -0,0 +1,79 @@
1
+ # controlzero policy file: MCP server template
2
+ # Schema version 1
3
+ #
4
+ # This template is for MCP (Model Context Protocol) servers. It assumes you
5
+ # expose tools to AI assistants like Claude Desktop, Claude Code, Cursor, etc.
6
+ #
7
+ # Goals:
8
+ # - Allow read/list/search tools by default
9
+ # - Require explicit allow for write/delete tools
10
+ # - Block dangerous operations entirely
11
+ #
12
+ # Run:
13
+ # controlzero validate controlzero.yaml
14
+ # controlzero test fs:delete --policy controlzero.yaml
15
+
16
+ version: '1'
17
+
18
+ rules:
19
+ # ---------- ALLOW: read-only operations across all MCP tools ----------
20
+ - id: allow-mcp-reads
21
+ allow: '*:read'
22
+ reason: 'All MCP read methods are allowed'
23
+
24
+ - id: allow-mcp-lists
25
+ allow: '*:list'
26
+ reason: 'All MCP list methods are allowed'
27
+
28
+ - id: allow-mcp-search
29
+ allow: '*:search'
30
+ reason: 'All MCP search methods are allowed'
31
+
32
+ - id: allow-mcp-get
33
+ allow: '*:get'
34
+ reason: 'All MCP get methods are allowed'
35
+
36
+ # ---------- ALLOW: scoped writes for known-good tools ----------
37
+ - id: allow-notes-write
38
+ allow: 'notes:create'
39
+ reason: 'Creating notes is fine'
40
+
41
+ - id: allow-tasks-update
42
+ allow: 'tasks:update'
43
+ reason: 'Updating task state is fine'
44
+
45
+ # ---------- DENY: dangerous filesystem operations ----------
46
+ - id: deny-fs-delete
47
+ deny: 'fs:delete'
48
+ reason: 'Filesystem deletes require human approval'
49
+
50
+ - id: deny-fs-write
51
+ deny: 'fs:write'
52
+ reason: 'Filesystem writes require explicit allow per path'
53
+
54
+ # ---------- DENY: code execution ----------
55
+ - id: deny-shell
56
+ deny: 'shell:*'
57
+ reason: 'Shell commands are blocked'
58
+
59
+ - id: deny-exec
60
+ deny: 'exec:*'
61
+ reason: 'Process execution is blocked'
62
+
63
+ # ---------- DENY: network egress ----------
64
+ - id: deny-http-post
65
+ deny: 'http:post'
66
+ reason: 'Outbound POST blocked, use a specific tool'
67
+
68
+ - id: deny-http-put
69
+ deny: 'http:put'
70
+ reason: 'Outbound PUT blocked, use a specific tool'
71
+
72
+ - id: deny-http-delete
73
+ deny: 'http:delete'
74
+ reason: 'Outbound DELETE blocked'
75
+
76
+ # ---------- DENY: catch-all ----------
77
+ - id: catch-all-deny
78
+ deny: '*'
79
+ reason: 'MCP tool not on the allow list. Add a rule above to permit it.'
@@ -0,0 +1,63 @@
1
+ # controlzero policy file: RAG / agent template
2
+ # Schema version 1
3
+ #
4
+ # This template is for retrieval-augmented generation (RAG) and agent apps.
5
+ # Goals:
6
+ # - Allow the agent to read from your knowledge base and search the web
7
+ # - Block exfiltration: writes to external systems, file deletes, code exec
8
+ # - Allow well-defined tool calls; deny anything novel
9
+ #
10
+ # Run:
11
+ # controlzero validate controlzero.yaml
12
+ # controlzero test exfiltrate_data --policy controlzero.yaml
13
+
14
+ version: '1'
15
+
16
+ rules:
17
+ # ---------- ALLOW: knowledge base reads ----------
18
+ - id: allow-vector-search
19
+ allow: 'vector_search'
20
+ reason: 'Vector search over our own knowledge base is safe'
21
+
22
+ - id: allow-document-fetch
23
+ allow: 'fetch_document'
24
+ reason: 'Fetching documents from our KB is safe'
25
+
26
+ - id: allow-web-search
27
+ allow: 'web_search'
28
+ reason: 'Read-only web search is allowed'
29
+
30
+ # ---------- ALLOW: model and tool inspection ----------
31
+ - id: allow-list-tools
32
+ allow: 'list_*'
33
+ reason: 'Listing/inspecting tools is read-only'
34
+
35
+ # ---------- DENY: exfiltration vectors ----------
36
+ - id: deny-http-post
37
+ deny: 'http_post'
38
+ reason: 'Outbound POST is a data exfiltration vector'
39
+
40
+ - id: deny-send-email
41
+ deny: 'send_email'
42
+ reason: 'Email sends require human approval'
43
+
44
+ - id: deny-shell-exec
45
+ deny: 'shell_exec'
46
+ reason: 'Shell execution is high risk'
47
+
48
+ - id: deny-eval
49
+ deny: 'eval'
50
+ reason: 'Code eval is forbidden'
51
+
52
+ - id: deny-file-writes
53
+ deny: 'write_*'
54
+ reason: 'File writes from agents are blocked'
55
+
56
+ - id: deny-file-deletes
57
+ deny: 'delete_*'
58
+ reason: 'File deletes are blocked'
59
+
60
+ # ---------- DENY: catch-all ----------
61
+ - id: catch-all-deny
62
+ deny: '*'
63
+ reason: 'Default deny: tool not on the allow list for this RAG app'
controlzero/client.py ADDED
@@ -0,0 +1,398 @@
1
+ """ControlZero Client. The user-facing entry point.
2
+
3
+ Three states, one client:
4
+
5
+ state policy source audit destination
6
+ ---------------------------------------------------------------------
7
+ no API key + local policy local (dict/file) local rotated log
8
+ API key + no local policy dashboard (hosted) remote audit trail
9
+ API key + local policy local OVERRIDES remote audit trail
10
+ + WARN log
11
+ no API key + no local policy none stderr (one warning)
12
+ + pass-through
13
+
14
+ Resolution order for finding a policy:
15
+ 1. explicit `policy=` or `policy_file=` arg
16
+ 2. CONTROLZERO_POLICY_FILE env var
17
+ 3. ./controlzero.yaml in cwd
18
+ 4. CONTROLZERO_API_KEY env var (hosted mode)
19
+ 5. nothing => no-op pass-through with one-time stderr warning
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ import warnings
27
+ from pathlib import Path
28
+ from typing import Any, Optional, Union
29
+
30
+ from controlzero._internal.dlp_scanner import (
31
+ DLPScanner,
32
+ load_dlp_rules_from_policy,
33
+ )
34
+ from controlzero._internal.enforcer import (
35
+ PolicyDecision,
36
+ PolicyDeniedError,
37
+ PolicyEvaluator,
38
+ )
39
+ from controlzero.audit_local import LocalAuditLogger
40
+ from controlzero.audit_remote import RemoteAuditSink
41
+ from controlzero.errors import (
42
+ HostedModeNotImplemented,
43
+ HybridModeError,
44
+ PolicyLoadError,
45
+ PolicyValidationError,
46
+ )
47
+ from controlzero.policy_loader import load_policy
48
+
49
+ # One-time warning state per process
50
+ _NO_POLICY_WARNED = False
51
+ _HYBRID_WARNED = False
52
+
53
+
54
+ class Client:
55
+ """The ControlZero policy client.
56
+
57
+ Hello World (no API key, no signup):
58
+
59
+ from controlzero import Client
60
+
61
+ cz = Client(policy={
62
+ "rules": [{"deny": "delete_*", "reason": "Hello World"}]
63
+ })
64
+
65
+ result = cz.guard("delete_file", {"path": "/tmp/foo"})
66
+ print(result.decision) # "deny"
67
+
68
+ Hosted mode (with API key, audit ships to dashboard):
69
+
70
+ cz = Client(api_key="cz_live_...") # or set CONTROLZERO_API_KEY env var
71
+
72
+ Hybrid (API key + local policy override): emits a WARN log on init.
73
+ Use `strict_hosted=True` to raise instead of warn.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ api_key: Optional[str] = None,
79
+ policy: Optional[dict] = None,
80
+ policy_file: Optional[Union[str, Path]] = None,
81
+ strict_hosted: bool = False,
82
+ log_path: str = "./controlzero.log",
83
+ log_rotation: str = "daily",
84
+ log_retention: str = "30 days",
85
+ log_compression: Optional[str] = None,
86
+ log_format: str = "json",
87
+ ):
88
+ if policy is not None and policy_file is not None:
89
+ raise ValueError(
90
+ "Pass either `policy` or `policy_file`, not both."
91
+ )
92
+
93
+ # Resolve API key from arg or env
94
+ self._api_key = api_key or os.environ.get("CONTROLZERO_API_KEY")
95
+
96
+ # Resolve local policy source
97
+ local_source = self._resolve_local_source(policy, policy_file)
98
+
99
+ # Decide mode
100
+ self._has_api_key = bool(self._api_key)
101
+ self._has_local_policy = local_source is not None
102
+
103
+ # SECURITY: Hosted mode is not implemented in this slim package.
104
+ # If a user sets CONTROLZERO_API_KEY without a local policy, they expect
105
+ # remote dashboard policies to enforce their tool calls. Silently
106
+ # returning "allow" for everything would be a security incident waiting
107
+ # to happen. Refuse to construct, loud and immediate.
108
+ if self._has_api_key and not self._has_local_policy:
109
+ raise HostedModeNotImplemented(
110
+ "controlzero: hosted mode (dashboard policies + remote audit) "
111
+ "is not yet implemented in this package.\n"
112
+ " - For local mode, provide a policy: Client(policy={...}) "
113
+ "or Client(policy_file='./controlzero.yaml')\n"
114
+ " - For hosted mode today, install the legacy package: "
115
+ "pip install 'control-zero<=0.3.0'\n"
116
+ " - Hosted mode in this package is coming in a future release."
117
+ )
118
+
119
+ # Hybrid detection: API key + local policy
120
+ if self._has_api_key and self._has_local_policy:
121
+ self._handle_hybrid(strict_hosted)
122
+
123
+ # Load local policy if present
124
+ self._evaluator: Optional[PolicyEvaluator] = None
125
+ if self._has_local_policy:
126
+ try:
127
+ rules = load_policy(local_source)
128
+ except (PolicyLoadError, PolicyValidationError):
129
+ # Re-raise: caller needs to fix their config
130
+ raise
131
+
132
+ # Initialize DLP scanner with built-in patterns + any custom
133
+ # dlp_rules from the policy file.
134
+ dlp_scanner = DLPScanner()
135
+ raw_policy_data = self._get_raw_policy_data(local_source)
136
+ if raw_policy_data:
137
+ custom_dlp = load_dlp_rules_from_policy(raw_policy_data)
138
+ if custom_dlp:
139
+ dlp_scanner.add_rules(custom_dlp)
140
+
141
+ self._evaluator = PolicyEvaluator(rules, dlp_scanner=dlp_scanner)
142
+
143
+ # Set up local audit logger ONLY in pure-local mode.
144
+ # When hosted, audit goes through the remote forwarder (not implemented in
145
+ # this skinny client; see hosted SDK in the legacy package for now).
146
+ self._audit: Optional[LocalAuditLogger] = None
147
+ if not self._has_api_key:
148
+ # log_* options are honored
149
+ self._audit = LocalAuditLogger(
150
+ log_path=log_path,
151
+ rotation=log_rotation,
152
+ retention=log_retention,
153
+ compression=log_compression,
154
+ log_format=log_format,
155
+ )
156
+ else:
157
+ # Hosted: log_* options are ignored. Warn if user tried to set them.
158
+ user_set_log_opts = (
159
+ log_path != "./controlzero.log"
160
+ or log_rotation != "daily"
161
+ or log_retention != "30 days"
162
+ or log_compression is not None
163
+ or log_format != "json"
164
+ )
165
+ if user_set_log_opts:
166
+ warnings.warn(
167
+ "controlzero: log_* options are ignored when an API key is set "
168
+ "(audit is managed server-side).",
169
+ UserWarning,
170
+ stacklevel=2,
171
+ )
172
+
173
+ # Set up remote audit sink if this machine is enrolled.
174
+ # The remote sink is additive: local file is always written first,
175
+ # then the entry is buffered for async POST to the backend.
176
+ self._remote_sink: Optional[RemoteAuditSink] = None
177
+ self._init_remote_sink()
178
+
179
+ # ---------------- public API ----------------
180
+
181
+ def guard(
182
+ self,
183
+ tool: str,
184
+ args: Optional[dict] = None,
185
+ method: str = "*",
186
+ raise_on_deny: bool = False,
187
+ context: Optional[dict] = None,
188
+ ) -> PolicyDecision:
189
+ """Evaluate a tool call against the loaded policy.
190
+
191
+ Args:
192
+ tool: The tool name (e.g. "delete_file", "github").
193
+ args: The arguments the tool would be called with. Logged for audit.
194
+ method: Optional method name. Defaults to "*" (any method).
195
+ raise_on_deny: If True, raises PolicyDeniedError on a deny decision.
196
+ context: Optional context dict with `resource` and `tags` keys for
197
+ resource-level matching and identity tags.
198
+
199
+ Returns:
200
+ PolicyDecision. Always returns; never raises unless raise_on_deny.
201
+ """
202
+ if self._evaluator is None:
203
+ return self._noop_decision(tool, method)
204
+
205
+ try:
206
+ decision = self._evaluator.evaluate(
207
+ tool, method, context=context, args=args,
208
+ )
209
+ except Exception as e:
210
+ # Fail closed on any evaluator crash. NEVER allow on error.
211
+ decision = PolicyDecision(
212
+ effect="deny",
213
+ reason=f"Evaluator error: {type(e).__name__}: {e}. Failing closed.",
214
+ )
215
+
216
+ self._audit_decision(tool, method, args or {}, decision, context=context)
217
+
218
+ if raise_on_deny and decision.denied:
219
+ raise PolicyDeniedError(decision)
220
+
221
+ return decision
222
+
223
+ def close(self) -> None:
224
+ """Flush and close audit sinks (local + remote)."""
225
+ if self._remote_sink is not None:
226
+ self._remote_sink.close()
227
+ self._remote_sink = None
228
+ if self._audit is not None:
229
+ self._audit.close()
230
+ self._audit = None
231
+
232
+ def __enter__(self):
233
+ return self
234
+
235
+ def __exit__(self, exc_type, exc, tb):
236
+ self.close()
237
+
238
+ # ---------------- internals ----------------
239
+
240
+ def _init_remote_sink(self) -> None:
241
+ """Create a RemoteAuditSink if this machine is enrolled.
242
+
243
+ Enrollment state lives in ~/.controlzero/enrollment.json. If the
244
+ file exists and is valid, we create a sink that will buffer audit
245
+ entries and POST them to the backend asynchronously. If enrollment
246
+ is absent or the enrollment module is not installed (no 'hosted'
247
+ extras), we silently skip -- local audit still works.
248
+ """
249
+ try:
250
+ from controlzero.enrollment import load_state
251
+ except ImportError:
252
+ # 'hosted' extras not installed -- no remote audit
253
+ return
254
+
255
+ try:
256
+ state = load_state()
257
+ except Exception:
258
+ return
259
+
260
+ if state is None:
261
+ return
262
+
263
+ if not state.api_url or not state.machine_id or not state.org_id:
264
+ return
265
+
266
+ try:
267
+ self._remote_sink = RemoteAuditSink(
268
+ api_url=state.api_url,
269
+ machine_token="", # auth via signed request headers
270
+ org_id=state.org_id,
271
+ machine_id=state.machine_id,
272
+ )
273
+ except Exception:
274
+ # Any failure creating the sink should not block the client
275
+ pass
276
+
277
+ @staticmethod
278
+ def _get_raw_policy_data(
279
+ source: Union[dict, Path],
280
+ ) -> Optional[dict]:
281
+ """Read the raw policy dict to extract non-rule sections (e.g. dlp_rules).
282
+
283
+ If source is already a dict, return it directly. If it is a file path,
284
+ parse it again to get the full dict (including sections that load_policy
285
+ strips out).
286
+ """
287
+ if isinstance(source, dict):
288
+ return source
289
+ if isinstance(source, Path) and source.exists():
290
+ import json as _json
291
+ text = source.read_text(encoding="utf-8")
292
+ suffix = source.suffix.lower()
293
+ if suffix in (".yaml", ".yml"):
294
+ try:
295
+ import yaml
296
+ return yaml.safe_load(text)
297
+ except Exception:
298
+ return None
299
+ elif suffix == ".json":
300
+ try:
301
+ return _json.loads(text)
302
+ except Exception:
303
+ return None
304
+ return None
305
+
306
+ def _resolve_local_source(
307
+ self,
308
+ policy: Optional[dict],
309
+ policy_file: Optional[Union[str, Path]],
310
+ ) -> Optional[Union[dict, Path]]:
311
+ """Find a local policy source by priority order."""
312
+ if policy is not None:
313
+ return policy
314
+ if policy_file is not None:
315
+ return Path(policy_file)
316
+
317
+ env_path = os.environ.get("CONTROLZERO_POLICY_FILE")
318
+ if env_path:
319
+ return Path(env_path)
320
+
321
+ cwd_default = Path.cwd() / "controlzero.yaml"
322
+ if cwd_default.exists():
323
+ return cwd_default
324
+
325
+ return None
326
+
327
+ def _handle_hybrid(self, strict: bool) -> None:
328
+ """API key + local policy = hybrid. Warn loudly or raise."""
329
+ global _HYBRID_WARNED
330
+ msg = (
331
+ "controlzero: manual policy override detected. "
332
+ "An API key is set AND a local policy was provided; the local policy "
333
+ "will be used and the dashboard policy will be IGNORED for this client "
334
+ "instance. Audit will still ship to the remote dashboard. "
335
+ "If this is unintentional, remove the local policy or unset CONTROLZERO_API_KEY."
336
+ )
337
+ if strict:
338
+ raise HybridModeError(msg + " (strict_hosted=True)")
339
+ if not _HYBRID_WARNED:
340
+ print("WARNING: " + msg, file=sys.stderr)
341
+ _HYBRID_WARNED = True
342
+
343
+ def _noop_decision(self, tool: str, method: str) -> PolicyDecision:
344
+ """No policy configured: pass through with a one-time warning."""
345
+ global _NO_POLICY_WARNED
346
+ if not _NO_POLICY_WARNED:
347
+ print(
348
+ "WARNING: controlzero: no policy configured, calls are not being checked. "
349
+ "Pass `policy=...`, `policy_file=...`, set CONTROLZERO_POLICY_FILE, "
350
+ "create ./controlzero.yaml, or set CONTROLZERO_API_KEY.",
351
+ file=sys.stderr,
352
+ )
353
+ _NO_POLICY_WARNED = True
354
+ return PolicyDecision(
355
+ effect="allow",
356
+ reason="No policy configured (pass-through)",
357
+ policy_id="<noop>",
358
+ )
359
+
360
+ def _audit_decision(
361
+ self,
362
+ tool: str,
363
+ method: str,
364
+ args: dict,
365
+ decision: PolicyDecision,
366
+ context: Optional[dict] = None,
367
+ ) -> None:
368
+ entry = {
369
+ "decision": decision.effect,
370
+ "tool": tool,
371
+ "method": method,
372
+ "policy_id": decision.policy_id,
373
+ "reason": decision.reason,
374
+ "args_keys": sorted(args.keys()),
375
+ "mode": "local",
376
+ }
377
+ # Include DLP findings in audit entry
378
+ if decision.dlp_findings:
379
+ entry["dlp_findings"] = decision.dlp_findings
380
+
381
+ # Include tamper detection flags from context if present
382
+ if context is not None:
383
+ if "tamper_detected" in context:
384
+ entry["tamper_detected"] = context["tamper_detected"]
385
+ if "audit_chain_broken" in context:
386
+ entry["audit_chain_broken"] = context["audit_chain_broken"]
387
+
388
+ # Local file first (always)
389
+ if self._audit is not None:
390
+ self._audit.log(entry)
391
+
392
+ # Remote sink second (best-effort, non-blocking)
393
+ if self._remote_sink is not None:
394
+ try:
395
+ self._remote_sink.log(entry)
396
+ except Exception:
397
+ # Never let remote sink failure affect the caller
398
+ pass