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.
- controlzero/__init__.py +41 -0
- controlzero/_internal/__init__.py +1 -0
- controlzero/_internal/dlp_scanner.py +777 -0
- controlzero/_internal/enforcer.py +210 -0
- controlzero/_internal/types.py +19 -0
- controlzero/audit_local.py +128 -0
- controlzero/audit_remote.py +221 -0
- controlzero/cli/__init__.py +1 -0
- controlzero/cli/main.py +1177 -0
- controlzero/cli/templates/autogen.yaml +79 -0
- controlzero/cli/templates/claude-code.yaml +85 -0
- controlzero/cli/templates/codex-cli.yaml +80 -0
- controlzero/cli/templates/cost-cap.yaml +64 -0
- controlzero/cli/templates/crewai.yaml +83 -0
- controlzero/cli/templates/cursor.yaml +86 -0
- controlzero/cli/templates/gemini-cli.yaml +85 -0
- controlzero/cli/templates/generic.yaml +57 -0
- controlzero/cli/templates/langchain.yaml +89 -0
- controlzero/cli/templates/mcp.yaml +79 -0
- controlzero/cli/templates/rag.yaml +63 -0
- controlzero/client.py +398 -0
- controlzero/enrollment.py +493 -0
- controlzero/errors.py +60 -0
- controlzero/policy_loader.py +245 -0
- controlzero/tamper.py +337 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/autogen.yaml +79 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/claude-code.yaml +85 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/codex-cli.yaml +80 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/cost-cap.yaml +64 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/crewai.yaml +83 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/cursor.yaml +86 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/gemini-cli.yaml +85 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/generic.yaml +57 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/langchain.yaml +89 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/mcp.yaml +79 -0
- controlzero-1.0.0.data/data/controlzero/cli/templates/rag.yaml +63 -0
- controlzero-1.0.0.dist-info/METADATA +232 -0
- controlzero-1.0.0.dist-info/RECORD +41 -0
- controlzero-1.0.0.dist-info/WHEEL +4 -0
- controlzero-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|