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,210 @@
1
+ """Local policy evaluator. Pure Python, in-process, fail-closed by default.
2
+
3
+ Copied from the proven control_zero.policy.enforcer module and adapted for the
4
+ local-first surface. The eval logic is identical so behavior is consistent
5
+ across hosted and local modes.
6
+
7
+ Pattern matching uses Python's stdlib fnmatch (shell-style globs). All friendly
8
+ shorthand (e.g. "delete_*" -> "delete_*:*") is normalized in policy_loader.py
9
+ BEFORE rules reach this evaluator. By the time rules arrive here, every action
10
+ is canonical "tool:method" form. This keeps the evaluator simple and matches
11
+ the legacy hosted engine's behavior.
12
+
13
+ DLP scanning (added 2026-04-09): after the tool-name policy check, the
14
+ evaluator optionally scans tool arguments for PII, secrets, and sensitive data
15
+ patterns. A "block" DLP match overrides an "allow" decision. "detect" matches
16
+ are recorded in the audit entry without blocking.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import fnmatch
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Optional
24
+
25
+ from controlzero._internal.dlp_scanner import (
26
+ DLPMatch,
27
+ DLPScanner,
28
+ extract_text_from_args,
29
+ )
30
+ from controlzero._internal.types import PolicyRule
31
+
32
+
33
+ @dataclass
34
+ class PolicyDecision:
35
+ """Result of a policy evaluation.
36
+
37
+ Attributes:
38
+ effect: One of "allow", "deny", "warn", "audit".
39
+ decision: Alias for effect, for ergonomic Hello World code.
40
+ policy_id: ID of the rule that matched, or None if no match.
41
+ reason: Human-readable reason for the decision.
42
+ evaluated_rules: How many rules were checked before a match.
43
+ dlp_findings: List of DLP match dicts for the audit trail.
44
+ """
45
+ effect: str
46
+ policy_id: Optional[str] = None
47
+ reason: Optional[str] = None
48
+ evaluated_rules: int = 0
49
+ dlp_findings: list[dict] = field(default_factory=list)
50
+
51
+ @property
52
+ def decision(self) -> str:
53
+ """Alias for `effect`. Reads more naturally in user code."""
54
+ return self.effect
55
+
56
+ @property
57
+ def allowed(self) -> bool:
58
+ """True if the call was allowed."""
59
+ return self.effect == "allow"
60
+
61
+ @property
62
+ def denied(self) -> bool:
63
+ """True if the call was denied."""
64
+ return self.effect == "deny"
65
+
66
+
67
+ class PolicyDeniedError(Exception):
68
+ """Raised by `Client.guard(..., raise_on_deny=True)` when a policy denies an action."""
69
+
70
+ def __init__(self, decision: PolicyDecision):
71
+ self.decision = decision
72
+ super().__init__(
73
+ f"Policy denied: {decision.reason or 'no reason provided'}"
74
+ )
75
+
76
+
77
+ class PolicyEvaluator:
78
+ """In-process policy evaluator. Fail-closed by default.
79
+
80
+ Loads a list of internal `PolicyRule` objects and evaluates tool calls
81
+ against them in order. The first matching rule wins. If no rule matches,
82
+ the call is denied (fail-closed).
83
+
84
+ Optionally accepts a DLPScanner to inspect tool arguments for sensitive
85
+ content after the tool-name policy check passes.
86
+
87
+ All pattern matching uses `fnmatch.fnmatchcase` (shell-style globs):
88
+ * matches everything
89
+ delete_* matches anything starting with "delete_"
90
+ github:* matches any method on the github tool
91
+ *:read matches the read method on any tool
92
+ model:*-haiku-* matches any model with -haiku- in its name
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ rules: Optional[list[PolicyRule]] = None,
98
+ dlp_scanner: Optional[DLPScanner] = None,
99
+ ):
100
+ self._rules: list[PolicyRule] = rules or []
101
+ self._dlp_scanner: Optional[DLPScanner] = dlp_scanner
102
+
103
+ def load(self, rules: list[PolicyRule]) -> None:
104
+ """Replace the rule set."""
105
+ self._rules = list(rules)
106
+
107
+ def set_dlp_scanner(self, scanner: DLPScanner) -> None:
108
+ """Set or replace the DLP scanner."""
109
+ self._dlp_scanner = scanner
110
+
111
+ def evaluate(
112
+ self,
113
+ tool: str,
114
+ method: str = "*",
115
+ context: Optional[dict] = None,
116
+ args: Optional[dict] = None,
117
+ ) -> PolicyDecision:
118
+ """Evaluate a tool call against the loaded rules.
119
+
120
+ Args:
121
+ tool: Tool name (e.g. "delete_file", "github").
122
+ method: Optional method name. Defaults to "*" which matches any.
123
+ context: Optional context dict with `resource` and `tags` keys.
124
+ args: Optional tool arguments dict. Scanned for DLP patterns if
125
+ a DLPScanner is configured.
126
+
127
+ Returns:
128
+ PolicyDecision. Always returns; never raises.
129
+ """
130
+ action = f"{tool}:{method}"
131
+ resource = (context or {}).get("resource")
132
+ evaluated = 0
133
+
134
+ for rule in self._rules:
135
+ evaluated += 1
136
+ if not _glob_any(rule.actions, action):
137
+ continue
138
+ if rule.resources:
139
+ if not resource or not _glob_any(rule.resources, resource):
140
+ continue
141
+ decision = PolicyDecision(
142
+ effect=rule.effect,
143
+ policy_id=rule.id or rule.name or None,
144
+ # User-provided reason wins. Falls back to canned text only if
145
+ # the rule had no `reason:` field in the source policy.
146
+ reason=rule.reason or f"Matched rule {rule.id or rule.name}".strip(),
147
+ evaluated_rules=evaluated,
148
+ )
149
+ # DLP scan: only when the policy decision is "allow" and args exist
150
+ if decision.allowed and args and self._dlp_scanner is not None:
151
+ decision = self._apply_dlp_scan(decision, args)
152
+ return decision
153
+
154
+ # Fail-closed default. No matching rule means deny.
155
+ return PolicyDecision(
156
+ effect="deny",
157
+ reason="No matching policy rule (fail-closed default)",
158
+ evaluated_rules=evaluated,
159
+ )
160
+
161
+ def _apply_dlp_scan(
162
+ self, decision: PolicyDecision, args: dict
163
+ ) -> PolicyDecision:
164
+ """Run DLP scanner on tool arguments and update decision if needed.
165
+
166
+ If any DLP rule has action="block", the decision is overridden to DENY.
167
+ If rules have action="detect" or "mask", findings are attached to the
168
+ decision for audit but the call is still allowed.
169
+ """
170
+ if self._dlp_scanner is None:
171
+ return decision
172
+
173
+ text = extract_text_from_args(args)
174
+ if not text:
175
+ return decision
176
+
177
+ matches = self._dlp_scanner.scan(text)
178
+ if not matches:
179
+ return decision
180
+
181
+ findings = self._dlp_scanner.get_findings_for_audit(matches)
182
+ decision.dlp_findings = findings
183
+
184
+ if self._dlp_scanner.has_blocking_match(matches):
185
+ # Find the first blocking rule for the reason message
186
+ blocking = next(m for m in matches if m.action == "block")
187
+ return PolicyDecision(
188
+ effect="deny",
189
+ policy_id=decision.policy_id,
190
+ reason=(
191
+ f"DLP rule '{blocking.rule_name}' matched "
192
+ f"{blocking.category} content in tool arguments"
193
+ ),
194
+ evaluated_rules=decision.evaluated_rules,
195
+ dlp_findings=findings,
196
+ )
197
+
198
+ return decision
199
+
200
+
201
+ def _glob_any(patterns: list[str], value: str) -> bool:
202
+ """True if any pattern matches the value via fnmatch.fnmatchcase.
203
+
204
+ fnmatchcase is case-sensitive (matches policy semantics) and supports the
205
+ same syntax users learn from .gitignore: *, ?, [seq], [!seq].
206
+ """
207
+ for p in patterns:
208
+ if fnmatch.fnmatchcase(value, p):
209
+ return True
210
+ return False
@@ -0,0 +1,19 @@
1
+ """Internal type definitions. Public users should import from controlzero, not here."""
2
+
3
+ from typing import Any, Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class PolicyRule(BaseModel):
8
+ """Internal canonical representation of a single policy rule.
9
+
10
+ The user-facing schema (see policy_loader) is friendlier; this is what the
11
+ evaluator consumes after translation.
12
+ """
13
+ id: str = ""
14
+ name: str = ""
15
+ effect: str # "allow" or "deny"
16
+ actions: list[str] = Field(default_factory=list)
17
+ resources: list[str] = Field(default_factory=list)
18
+ conditions: dict[str, Any] = Field(default_factory=dict)
19
+ reason: str = "" # Human-readable explanation, surfaced in audit + denies
@@ -0,0 +1,128 @@
1
+ """Local audit log sink built on loguru.
2
+
3
+ When the SDK runs without an API key, audit entries are written to a local file
4
+ with rotation. This module isolates the loguru config so the rest of the SDK
5
+ does not depend on loguru directly.
6
+
7
+ When an API key is set, this module is bypassed entirely and audit goes to the
8
+ remote forwarder. The user-supplied log_* options are ignored with a warning
9
+ (see Client.__init__).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+
21
+ class LocalAuditLogger:
22
+ """Writes audit entries to a local rotated log file via loguru.
23
+
24
+ Falls back to stderr if the configured log path is not writable.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ log_path: str = "./controlzero.log",
30
+ rotation: str = "daily",
31
+ retention: str = "30 days",
32
+ compression: Optional[str] = None,
33
+ log_format: str = "json",
34
+ ):
35
+ self._log_path = log_path
36
+ self._rotation = rotation
37
+ self._retention = retention
38
+ self._compression = compression
39
+ self._format = log_format
40
+ self._logger = None
41
+ self._sink_id: Optional[int] = None
42
+ self._fallback = False
43
+ # Per-instance component tag prevents cross-instance log duplication
44
+ # when multiple Client objects exist in the same process. Each loguru
45
+ # sink filters on its own component string.
46
+ self._component = f"controlzero.audit.{id(self)}"
47
+ self._setup()
48
+
49
+ def _setup(self) -> None:
50
+ try:
51
+ from loguru import logger as _logger
52
+ except ImportError:
53
+ print(
54
+ "controlzero: loguru not installed, audit logs will go to stderr.",
55
+ file=sys.stderr,
56
+ )
57
+ self._fallback = True
58
+ return
59
+
60
+ path = Path(self._log_path)
61
+ try:
62
+ # Make sure the parent directory exists and is writable
63
+ parent = path.parent
64
+ if str(parent) and not parent.exists():
65
+ parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ # Translate "daily" sugar to a loguru-friendly value
68
+ rotation_value: Any = self._rotation
69
+ if self._rotation == "daily":
70
+ rotation_value = "00:00"
71
+
72
+ self._logger = _logger.bind(component=self._component)
73
+ # Add a dedicated sink filtered on this instance's component tag,
74
+ # so each Client gets its own sink and lines never duplicate
75
+ # across instances. Capture self._component in the lambda's
76
+ # default arg to avoid late-binding issues.
77
+ comp = self._component
78
+ self._sink_id = _logger.add(
79
+ str(path),
80
+ rotation=rotation_value,
81
+ retention=self._retention,
82
+ compression=self._compression,
83
+ serialize=False, # we format ourselves
84
+ format="{message}",
85
+ filter=lambda record, c=comp: record["extra"].get("component") == c,
86
+ enqueue=True, # async-safe
87
+ )
88
+ except (OSError, PermissionError) as e:
89
+ print(
90
+ f"controlzero: cannot write to {self._log_path} ({e}), "
91
+ "falling back to stderr.",
92
+ file=sys.stderr,
93
+ )
94
+ self._fallback = True
95
+
96
+ def log(self, entry: dict) -> None:
97
+ """Write a single audit entry."""
98
+ line = self._format_entry(entry)
99
+ if self._fallback or self._logger is None:
100
+ print(line, file=sys.stderr)
101
+ return
102
+ self._logger.info(line)
103
+
104
+ def _format_entry(self, entry: dict) -> str:
105
+ # Always include a timestamp in UTC ISO 8601
106
+ record = {
107
+ "ts": datetime.now(timezone.utc).isoformat(),
108
+ **entry,
109
+ }
110
+ if self._format == "pretty":
111
+ # Compact human-readable form: ts | decision | tool | reason
112
+ parts = [
113
+ record.get("ts", ""),
114
+ record.get("decision", "?"),
115
+ record.get("tool", "?"),
116
+ record.get("reason", ""),
117
+ ]
118
+ return " | ".join(str(p) for p in parts)
119
+ return json.dumps(record, default=str)
120
+
121
+ def close(self) -> None:
122
+ if self._logger is not None and self._sink_id is not None:
123
+ try:
124
+ from loguru import logger as _logger
125
+ _logger.remove(self._sink_id)
126
+ except Exception:
127
+ pass
128
+ self._sink_id = None
@@ -0,0 +1,221 @@
1
+ """Remote audit sink -- batches local audit entries and POSTs them to the backend.
2
+
3
+ When an enrolled SDK evaluates a tool call, the decision is written to the local
4
+ audit log first (fire-and-forget). This module buffers those entries and
5
+ periodically flushes them to ``POST /api/audit`` on the backend so the
6
+ governance admin dashboard shows real data from CLI hooks (Claude Code,
7
+ Gemini CLI, Codex CLI, etc.).
8
+
9
+ Design constraints:
10
+ - NEVER block or crash the hook-check critical path. Local audit is
11
+ always written first; remote is best-effort.
12
+ - Thread-safe: multiple guard() calls from different threads share
13
+ one buffer protected by a lock.
14
+ - Flush runs in a daemon thread so shutdown of the main process is
15
+ not blocked by a slow network.
16
+ - On 401 the sink disables itself (enrollment expired).
17
+ - On transient errors the buffer is retained for the next flush cycle.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import getpass
23
+ import json
24
+ import logging
25
+ import platform
26
+ import threading
27
+ import time
28
+ import uuid
29
+ from datetime import datetime, timezone
30
+ from typing import Optional
31
+
32
+ logger = logging.getLogger("controlzero.audit_remote")
33
+
34
+ # Buffer limits
35
+ MAX_BUFFER_SIZE = 50
36
+ FLUSH_INTERVAL_S = 30.0
37
+ FLUSH_TIMEOUT_S = 2.0
38
+
39
+
40
+ class RemoteAuditSink:
41
+ """Buffers audit entries and flushes them to the backend in batches."""
42
+
43
+ def __init__(
44
+ self,
45
+ api_url: str,
46
+ machine_token: str, # not used for auth header, kept for future
47
+ org_id: str,
48
+ machine_id: str,
49
+ state_dir: Optional["Path"] = None,
50
+ ):
51
+ self._api_url = api_url.rstrip("/")
52
+ self._machine_token = machine_token
53
+ self._org_id = org_id
54
+ self._machine_id = machine_id
55
+ self._state_dir = state_dir
56
+
57
+ self._buffer: list[dict] = []
58
+ self._lock = threading.Lock()
59
+ self._disabled = False
60
+ self._closed = False
61
+
62
+ # Start the periodic flush timer
63
+ self._start_flush_timer()
64
+
65
+ # ---- public API ----
66
+
67
+ def log(self, entry: dict) -> None:
68
+ """Append an audit entry to the buffer. Triggers flush if buffer is full."""
69
+ if self._disabled or self._closed:
70
+ return
71
+ with self._lock:
72
+ self._buffer.append(self._to_wire_format(entry))
73
+ should_flush = len(self._buffer) >= MAX_BUFFER_SIZE
74
+ if should_flush:
75
+ self._flush_async()
76
+
77
+ def close(self) -> None:
78
+ """Final flush on shutdown. Blocks briefly to drain the buffer."""
79
+ if self._closed:
80
+ return
81
+ self._closed = True
82
+ self._cancel_flush_timer()
83
+ # Synchronous flush -- we are shutting down
84
+ self._flush_sync()
85
+
86
+ # ---- wire format mapping ----
87
+
88
+ def _to_wire_format(self, entry: dict) -> dict:
89
+ """Map from the local audit entry shape to the backend wire format.
90
+
91
+ Backend expects (from audit_ingest_handler.go AuditIngestEntry):
92
+ id, user, tool_name, decision, policy_id, rule_id, reason,
93
+ hostname, mode, ts, verbosity_level
94
+ """
95
+ hostname = platform.node() or "unknown"
96
+ user = ""
97
+ try:
98
+ user = getpass.getuser()
99
+ except Exception:
100
+ pass
101
+
102
+ return {
103
+ "id": str(uuid.uuid4()),
104
+ "tool_name": entry.get("tool", ""),
105
+ "decision": entry.get("decision", "allow"),
106
+ "policy_id": entry.get("policy_id", ""),
107
+ "rule_id": entry.get("policy_id", ""), # local entries use policy_id as rule_id
108
+ "reason": entry.get("reason", ""),
109
+ "hostname": hostname,
110
+ "user": user,
111
+ "mode": entry.get("mode", "local"),
112
+ "ts": datetime.now(timezone.utc).isoformat(),
113
+ }
114
+
115
+ # ---- flush mechanics ----
116
+
117
+ def _flush_async(self) -> None:
118
+ """Run flush in a daemon thread so it never blocks the caller."""
119
+ t = threading.Thread(target=self._flush_sync, daemon=True)
120
+ t.start()
121
+
122
+ def _flush_sync(self) -> None:
123
+ """POST buffered entries to the backend. On success, clear buffer."""
124
+ with self._lock:
125
+ if not self._buffer or self._disabled:
126
+ return
127
+ batch = list(self._buffer)
128
+
129
+ try:
130
+ self._post_batch(batch)
131
+ # Success: remove the flushed entries from the buffer
132
+ with self._lock:
133
+ # Only remove the entries we successfully sent; new entries
134
+ # may have been appended while we were posting.
135
+ self._buffer = self._buffer[len(batch):]
136
+ except _AuthExpiredError:
137
+ logger.warning(
138
+ "controlzero: remote audit sink disabled -- enrollment expired (401)"
139
+ )
140
+ with self._lock:
141
+ self._disabled = True
142
+ self._buffer.clear()
143
+ except Exception as exc:
144
+ # Transient error: keep entries in buffer for next flush
145
+ logger.warning(
146
+ "controlzero: remote audit flush failed (%s); "
147
+ "entries retained for retry",
148
+ exc,
149
+ )
150
+
151
+ def _post_batch(self, batch: list[dict]) -> None:
152
+ """Send a batch of entries to POST /api/audit with signed headers."""
153
+ try:
154
+ from controlzero.enrollment import load_state, sign_request
155
+ except ImportError:
156
+ # enrollment module not available -- cannot send
157
+ raise _AuthExpiredError("enrollment module not available")
158
+
159
+ state = load_state(self._state_dir) if self._state_dir else load_state()
160
+ if state is None:
161
+ raise _AuthExpiredError("no enrollment state")
162
+
163
+ body = json.dumps({"entries": batch}).encode("utf-8")
164
+ state_dir_kwarg = {"state_dir": self._state_dir} if self._state_dir else {}
165
+ headers = sign_request(state, "POST", "/api/audit", body, **state_dir_kwarg)
166
+ headers["Content-Type"] = "application/json"
167
+
168
+ try:
169
+ import httpx
170
+ except ImportError:
171
+ raise _AuthExpiredError("httpx not available")
172
+
173
+ resp = httpx.post(
174
+ f"{self._api_url}/api/audit",
175
+ content=body,
176
+ headers=headers,
177
+ timeout=FLUSH_TIMEOUT_S,
178
+ )
179
+ if resp.status_code == 401:
180
+ raise _AuthExpiredError("server returned 401")
181
+ if resp.status_code >= 400:
182
+ raise RuntimeError(
183
+ f"audit ingest returned HTTP {resp.status_code}: {resp.text}"
184
+ )
185
+
186
+ # ---- periodic timer ----
187
+
188
+ def _start_flush_timer(self) -> None:
189
+ """Schedule the next periodic flush."""
190
+ if self._closed or self._disabled:
191
+ return
192
+ self._timer = threading.Timer(FLUSH_INTERVAL_S, self._on_timer)
193
+ self._timer.daemon = True
194
+ self._timer.start()
195
+
196
+ def _cancel_flush_timer(self) -> None:
197
+ timer = getattr(self, "_timer", None)
198
+ if timer is not None:
199
+ timer.cancel()
200
+
201
+ def _on_timer(self) -> None:
202
+ """Called by the periodic timer. Flush and reschedule."""
203
+ if self._closed or self._disabled:
204
+ return
205
+ self._flush_sync()
206
+ self._start_flush_timer()
207
+
208
+ # ---- properties for testing ----
209
+
210
+ @property
211
+ def disabled(self) -> bool:
212
+ return self._disabled
213
+
214
+ @property
215
+ def buffer(self) -> list[dict]:
216
+ with self._lock:
217
+ return list(self._buffer)
218
+
219
+
220
+ class _AuthExpiredError(Exception):
221
+ """Internal: signals the remote sink should be permanently disabled."""
@@ -0,0 +1 @@
1
+ """Command-line interface for the controlzero SDK."""