glacis 0.1.3__py3-none-any.whl → 0.2.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.
glacis/crypto.py CHANGED
@@ -19,7 +19,8 @@ Example:
19
19
 
20
20
  import hashlib
21
21
  import json
22
- from typing import Any
22
+ from base64 import b64encode
23
+ from typing import Any, Optional
23
24
 
24
25
 
25
26
  def canonical_json(data: Any) -> str:
@@ -119,3 +120,71 @@ def hash_bytes(data: bytes) -> str:
119
120
  Hex-encoded SHA-256 hash (64 characters)
120
121
  """
121
122
  return hashlib.sha256(data).hexdigest()
123
+
124
+
125
+ # =============================================================================
126
+ # Ed25519 Signing (for offline attestations)
127
+ # =============================================================================
128
+
129
+ class CryptoError(Exception):
130
+ """Error from cryptographic operations."""
131
+ pass
132
+
133
+
134
+ class Ed25519Runtime:
135
+ """
136
+ Ed25519 cryptographic runtime using PyNaCl (libsodium bindings).
137
+
138
+ Provides signing and verification for offline attestations.
139
+ """
140
+
141
+ def __init__(self) -> None:
142
+ try:
143
+ import nacl.signing
144
+ self._nacl_signing = nacl.signing
145
+ except ImportError:
146
+ raise CryptoError(
147
+ "PyNaCl not installed. Install with: pip install pynacl"
148
+ )
149
+
150
+ def get_public_key_hex(self, seed: bytes) -> str:
151
+ """Get hex-encoded public key from a 32-byte seed."""
152
+ if len(seed) != 32:
153
+ raise ValueError("Seed must be exactly 32 bytes")
154
+ signing_key = self._nacl_signing.SigningKey(seed)
155
+ return bytes(signing_key.verify_key).hex()
156
+
157
+ def sign(self, seed: bytes, message: bytes) -> bytes:
158
+ """Sign a message with Ed25519, returning 64-byte signature."""
159
+ if len(seed) != 32:
160
+ raise ValueError("Seed must be exactly 32 bytes")
161
+ signing_key = self._nacl_signing.SigningKey(seed)
162
+ signed = signing_key.sign(message)
163
+ return signed.signature
164
+
165
+ def sign_attestation_json(self, seed: bytes, attestation_json: str) -> str:
166
+ """Sign an attestation JSON and return SignedAttestation JSON."""
167
+ if len(seed) != 32:
168
+ raise ValueError("Seed must be exactly 32 bytes")
169
+
170
+ json_bytes = attestation_json.encode("utf-8")
171
+ signature = self.sign(seed, json_bytes)
172
+ signature_b64 = b64encode(signature).decode("ascii")
173
+
174
+ payload = json.loads(attestation_json)
175
+ return json.dumps({
176
+ "payload": payload,
177
+ "signature": signature_b64,
178
+ }, separators=(",", ":"))
179
+
180
+
181
+ # Singleton instance
182
+ _ed25519_runtime: Optional[Ed25519Runtime] = None
183
+
184
+
185
+ def get_ed25519_runtime() -> Ed25519Runtime:
186
+ """Get the singleton Ed25519 runtime instance."""
187
+ global _ed25519_runtime
188
+ if _ed25519_runtime is None:
189
+ _ed25519_runtime = Ed25519Runtime()
190
+ return _ed25519_runtime
@@ -1,12 +1,62 @@
1
1
  """
2
- GLACIS integrations for popular AI providers.
2
+ GLACIS integrations for AI providers.
3
3
 
4
4
  These integrations provide drop-in wrappers that automatically attest
5
- all API calls to the GLACIS transparency log.
5
+ all API calls to the GLACIS transparency log with optional PII/PHI redaction.
6
6
 
7
7
  Available integrations:
8
8
  - OpenAI: `from glacis.integrations.openai import attested_openai`
9
9
  - Anthropic: `from glacis.integrations.anthropic import attested_anthropic`
10
+
11
+ Example (OpenAI):
12
+ >>> from glacis.integrations.openai import attested_openai, get_last_receipt
13
+ >>> client = attested_openai(
14
+ ... openai_api_key="sk-xxx",
15
+ ... offline=True,
16
+ ... signing_seed=os.urandom(32),
17
+ ... redaction="fast", # Enable PII redaction
18
+ ... )
19
+ >>> response = client.chat.completions.create(
20
+ ... model="gpt-4o",
21
+ ... messages=[{"role": "user", "content": "Hello!"}]
22
+ ... )
23
+ >>> receipt = get_last_receipt()
24
+
25
+ Example (Anthropic):
26
+ >>> from glacis.integrations.anthropic import attested_anthropic, get_last_receipt
27
+ >>> client = attested_anthropic(
28
+ ... anthropic_api_key="sk-ant-xxx",
29
+ ... offline=True,
30
+ ... signing_seed=os.urandom(32),
31
+ ... redaction="fast",
32
+ ... )
33
+ >>> response = client.messages.create(
34
+ ... model="claude-3-5-sonnet-20241022",
35
+ ... max_tokens=1024,
36
+ ... messages=[{"role": "user", "content": "Hello!"}]
37
+ ... )
38
+
39
+ Note: For Azure OpenAI, use the openai package with azure endpoint configuration.
10
40
  """
11
41
 
12
- __all__ = []
42
+ from glacis.integrations.anthropic import attested_anthropic
43
+ from glacis.integrations.base import (
44
+ GlacisBlockedError,
45
+ get_evidence,
46
+ get_last_receipt,
47
+ )
48
+ from glacis.integrations.openai import attested_openai
49
+
50
+ # Backwards compatible aliases
51
+ get_last_openai_receipt = get_last_receipt
52
+ get_last_anthropic_receipt = get_last_receipt
53
+
54
+ __all__ = [
55
+ "attested_openai",
56
+ "attested_anthropic",
57
+ "get_last_receipt",
58
+ "get_last_openai_receipt",
59
+ "get_last_anthropic_receipt",
60
+ "get_evidence",
61
+ "GlacisBlockedError",
62
+ ]
@@ -1,62 +1,82 @@
1
1
  """
2
2
  GLACIS integration for Anthropic.
3
3
 
4
- Provides an attested Anthropic client wrapper that automatically logs all
5
- messages to the GLACIS transparency log.
4
+ Provides an attested Anthropic client wrapper that automatically:
5
+ 1. Runs enabled controls (PII/PHI redaction, jailbreak detection, etc.)
6
+ 2. Logs all completions to the GLACIS transparency log
7
+ 3. Creates control plane attestations
6
8
 
7
9
  Example:
8
- >>> from glacis.integrations.anthropic import attested_anthropic
9
- >>> client = attested_anthropic(glacis_api_key="glsk_live_xxx", anthropic_api_key="sk-xxx")
10
+ >>> from glacis.integrations.anthropic import attested_anthropic, get_last_receipt
11
+ >>> client = attested_anthropic(
12
+ ... anthropic_api_key="sk-ant-xxx",
13
+ ... offline=True,
14
+ ... signing_seed=os.urandom(32),
15
+ ... )
10
16
  >>> response = client.messages.create(
11
- ... model="claude-3-opus-20240229",
17
+ ... model="claude-3-5-sonnet-20241022",
18
+ ... max_tokens=1024,
12
19
  ... messages=[{"role": "user", "content": "Hello!"}]
13
20
  ... )
14
- # Response is automatically attested to GLACIS
21
+ >>> receipt = get_last_receipt()
15
22
  """
16
23
 
17
- from typing import TYPE_CHECKING, Any, Optional
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Union
27
+
28
+ from glacis.integrations.base import (
29
+ GlacisBlockedError,
30
+ create_controls_runner,
31
+ create_glacis_client,
32
+ get_evidence,
33
+ get_last_receipt,
34
+ initialize_config,
35
+ set_last_receipt,
36
+ store_evidence,
37
+ suppress_noisy_loggers,
38
+ )
18
39
 
19
40
  if TYPE_CHECKING:
20
41
  from anthropic import Anthropic
21
42
 
22
43
 
23
44
  def attested_anthropic(
24
- glacis_api_key: str,
45
+ glacis_api_key: Optional[str] = None,
25
46
  anthropic_api_key: Optional[str] = None,
26
47
  glacis_base_url: str = "https://api.glacis.io",
27
48
  service_id: str = "anthropic",
28
49
  debug: bool = False,
50
+ offline: Optional[bool] = None,
51
+ signing_seed: Optional[bytes] = None,
52
+ redaction: Union[bool, Literal["fast", "full"], None] = None,
53
+ config: Optional[str] = None,
29
54
  **anthropic_kwargs: Any,
30
55
  ) -> "Anthropic":
31
56
  """
32
- Create an attested Anthropic client.
33
-
34
- All messages are automatically attested to the GLACIS transparency log.
35
- The input (messages) and output (response) are hashed locally - the actual
36
- content never leaves your infrastructure.
57
+ Create an attested Anthropic client with controls (PII redaction, jailbreak detection).
37
58
 
38
59
  Args:
39
- glacis_api_key: GLACIS API key
60
+ glacis_api_key: GLACIS API key (required for online mode)
40
61
  anthropic_api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var)
41
62
  glacis_base_url: GLACIS API base URL
42
63
  service_id: Service identifier for attestations
43
64
  debug: Enable debug logging
65
+ offline: Enable offline mode (local signing, no server)
66
+ signing_seed: 32-byte Ed25519 signing seed (required for offline mode)
67
+ redaction: PII/PHI redaction mode - "fast", "full", True, False, or None
68
+ config: Path to glacis.yaml config file
44
69
  **anthropic_kwargs: Additional arguments passed to Anthropic client
45
70
 
46
71
  Returns:
47
72
  Wrapped Anthropic client
48
73
 
49
- Example:
50
- >>> client = attested_anthropic(
51
- ... glacis_api_key="glsk_live_xxx",
52
- ... anthropic_api_key="sk-xxx"
53
- ... )
54
- >>> response = client.messages.create(
55
- ... model="claude-3-opus-20240229",
56
- ... max_tokens=1024,
57
- ... messages=[{"role": "user", "content": "Hello!"}]
58
- ... )
74
+ Raises:
75
+ GlacisBlockedError: If a control blocks the request
59
76
  """
77
+ # Suppress noisy loggers
78
+ suppress_noisy_loggers(["anthropic", "anthropic._base_client"])
79
+
60
80
  try:
61
81
  from anthropic import Anthropic
62
82
  except ImportError:
@@ -65,11 +85,24 @@ def attested_anthropic(
65
85
  "Install it with: pip install glacis[anthropic]"
66
86
  )
67
87
 
68
- from glacis import Glacis
69
88
 
70
- glacis = Glacis(
71
- api_key=glacis_api_key,
72
- base_url=glacis_base_url,
89
+ # Initialize config and determine modes
90
+ cfg, effective_offline, effective_service_id = initialize_config(
91
+ config_path=config,
92
+ redaction=redaction,
93
+ offline=offline,
94
+ glacis_api_key=glacis_api_key,
95
+ default_service_id="anthropic",
96
+ service_id=service_id,
97
+ )
98
+
99
+ # Create controls runner and Glacis client
100
+ controls_runner = create_controls_runner(cfg, debug)
101
+ glacis = create_glacis_client(
102
+ offline=effective_offline,
103
+ signing_seed=signing_seed,
104
+ glacis_api_key=glacis_api_key,
105
+ glacis_base_url=glacis_base_url,
73
106
  debug=debug,
74
107
  )
75
108
 
@@ -84,132 +117,157 @@ def attested_anthropic(
84
117
  original_create = client.messages.create
85
118
 
86
119
  def attested_create(*args: Any, **kwargs: Any) -> Any:
87
- # Extract input
120
+ if kwargs.get("stream", False):
121
+ raise NotImplementedError(
122
+ "Streaming is not currently supported with attested_anthropic. "
123
+ "Use stream=False for now."
124
+ )
125
+
88
126
  messages = kwargs.get("messages", [])
89
127
  model = kwargs.get("model", "unknown")
90
128
  system = kwargs.get("system")
91
129
 
92
- # Make the API call
93
- response = original_create(*args, **kwargs)
94
-
95
- # Attest the interaction
96
- try:
97
- input_data: dict[str, Any] = {
98
- "model": model,
99
- "messages": messages,
100
- }
101
- if system:
102
- input_data["system"] = system
103
-
104
- glacis.attest(
105
- service_id=service_id,
106
- operation_type="completion",
107
- input=input_data,
108
- output={
109
- "model": response.model,
110
- "content": [
111
- {
112
- "type": block.type,
113
- "text": getattr(block, "text", None),
114
- }
115
- for block in response.content
116
- ],
117
- "stop_reason": response.stop_reason,
118
- "usage": {
119
- "input_tokens": response.usage.input_tokens,
120
- "output_tokens": response.usage.output_tokens,
121
- },
122
- },
123
- metadata={"provider": "anthropic", "model": model},
130
+ # Run controls if enabled
131
+ if controls_runner:
132
+ from glacis.integrations.base import (
133
+ ControlResultsAccumulator,
134
+ create_control_plane_attestation_from_accumulator,
135
+ handle_blocked_request,
136
+ process_text_for_controls,
124
137
  )
125
- except Exception as e:
126
- if debug:
127
- print(f"[glacis] Attestation failed: {e}")
128
-
129
- return response
130
-
131
- # Replace the create method
132
- client.messages.create = attested_create # type: ignore
133
-
134
- return client
135
-
136
-
137
- def attested_async_anthropic(
138
- glacis_api_key: str,
139
- anthropic_api_key: Optional[str] = None,
140
- glacis_base_url: str = "https://api.glacis.io",
141
- service_id: str = "anthropic",
142
- debug: bool = False,
143
- **anthropic_kwargs: Any,
144
- ) -> Any:
145
- """
146
- Create an attested async Anthropic client.
147
138
 
148
- Same as `attested_anthropic` but for async usage.
139
+ accumulator = ControlResultsAccumulator()
140
+
141
+ # Process system prompt through controls
142
+ # (system prompt is always "new" in current context)
143
+ if system and isinstance(system, str):
144
+ final_system = process_text_for_controls(
145
+ controls_runner, system, accumulator
146
+ )
147
+ kwargs["system"] = final_system
148
+
149
+ # Process messages with delta scanning (only scan last user message)
150
+ processed_messages = []
151
+
152
+ # Find the last user message index
153
+ last_user_idx = -1
154
+ for i, msg in enumerate(messages):
155
+ if isinstance(msg, dict) and msg.get("role") == "user":
156
+ last_user_idx = i
157
+
158
+ for i, msg in enumerate(messages):
159
+ role = msg.get("role", "") if isinstance(msg, dict) else ""
160
+
161
+ # Only run controls on the LAST user message (the new one)
162
+ if role == "user" and i == last_user_idx:
163
+ if isinstance(msg, dict) and isinstance(msg.get("content"), str):
164
+ content = msg["content"]
165
+ final_text = process_text_for_controls(
166
+ controls_runner, content, accumulator
167
+ )
168
+ processed_messages.append({**msg, "content": final_text})
169
+
170
+ elif isinstance(msg, dict) and isinstance(msg.get("content"), list):
171
+ # Handle content blocks (text, image, etc.)
172
+ redacted_content = []
173
+ for block in msg["content"]:
174
+ if isinstance(block, dict) and block.get("type") == "text":
175
+ text = block.get("text", "")
176
+ final_text = process_text_for_controls(
177
+ controls_runner, text, accumulator
178
+ )
179
+ redacted_content.append({**block, "text": final_text})
180
+ else:
181
+ redacted_content.append(block)
182
+ processed_messages.append({**msg, "content": redacted_content})
183
+ else:
184
+ processed_messages.append(msg)
185
+ else:
186
+ processed_messages.append(msg)
187
+
188
+ kwargs["messages"] = processed_messages
189
+ messages = processed_messages
149
190
 
150
- Example:
151
- >>> client = attested_async_anthropic(glacis_api_key="glsk_live_xxx")
152
- >>> response = await client.messages.create(...)
153
- """
154
- try:
155
- from anthropic import AsyncAnthropic
156
- except ImportError:
157
- raise ImportError(
158
- "Anthropic integration requires the 'anthropic' package. "
159
- "Install it with: pip install glacis[anthropic]"
160
- )
161
-
162
- from glacis import AsyncGlacis
163
-
164
- glacis = AsyncGlacis(
165
- api_key=glacis_api_key,
166
- base_url=glacis_base_url,
167
- debug=debug,
168
- )
169
-
170
- client_kwargs: dict[str, Any] = {**anthropic_kwargs}
171
- if anthropic_api_key:
172
- client_kwargs["api_key"] = anthropic_api_key
173
-
174
- client = AsyncAnthropic(**client_kwargs)
175
-
176
- original_create = client.messages.create
177
-
178
- async def attested_create(*args: Any, **kwargs: Any) -> Any:
179
- messages = kwargs.get("messages", [])
180
- model = kwargs.get("model", "unknown")
181
- system = kwargs.get("system")
191
+ if debug:
192
+ if accumulator.pii_summary:
193
+ print(
194
+ f"[glacis] PII redacted: {accumulator.pii_summary.categories} "
195
+ f"({accumulator.pii_summary.count} items)"
196
+ )
197
+ if accumulator.jailbreak_summary and accumulator.jailbreak_summary.detected:
198
+ print(
199
+ f"[glacis] Jailbreak detected: "
200
+ f"score={accumulator.jailbreak_summary.score:.2f}, "
201
+ f"action={accumulator.jailbreak_summary.action}"
202
+ )
203
+
204
+ # Build control plane attestation
205
+ control_plane_results = create_control_plane_attestation_from_accumulator(
206
+ accumulator, cfg, model, "anthropic", "messages.create"
207
+ )
182
208
 
183
- response = await original_create(*args, **kwargs)
209
+ # Check if we need to block BEFORE making the API call
210
+ if accumulator.should_block:
211
+ handle_blocked_request(
212
+ glacis_client=glacis,
213
+ service_id=effective_service_id,
214
+ input_data={
215
+ "model": model,
216
+ "messages": messages,
217
+ "system": kwargs.get("system")
218
+ },
219
+ control_plane_results=control_plane_results,
220
+ provider="anthropic",
221
+ model=model,
222
+ jailbreak_score=accumulator.jailbreak_summary.score
223
+ if accumulator.jailbreak_summary
224
+ else 0.0,
225
+ debug=debug,
226
+ )
227
+ else:
228
+ control_plane_results = None
229
+
230
+ # Make the API call (only if not blocked)
231
+ response = original_create(*args, **kwargs)
184
232
 
233
+ # Build input/output data
234
+ input_data: dict[str, Any] = {"model": model, "messages": messages}
235
+ if system:
236
+ input_data["system"] = kwargs.get("system", system)
237
+
238
+ output_data = {
239
+ "model": response.model,
240
+ "content": [
241
+ {"type": block.type, "text": getattr(block, "text", None)}
242
+ for block in response.content
243
+ ],
244
+ "stop_reason": response.stop_reason,
245
+ "usage": {
246
+ "input_tokens": response.usage.input_tokens,
247
+ "output_tokens": response.usage.output_tokens,
248
+ },
249
+ }
250
+
251
+ # Attest and store
185
252
  try:
186
- input_data: dict[str, Any] = {
187
- "model": model,
188
- "messages": messages,
189
- }
190
- if system:
191
- input_data["system"] = system
192
-
193
- await glacis.attest(
194
- service_id=service_id,
253
+ receipt = glacis.attest(
254
+ service_id=effective_service_id,
195
255
  operation_type="completion",
196
256
  input=input_data,
197
- output={
198
- "model": response.model,
199
- "content": [
200
- {
201
- "type": block.type,
202
- "text": getattr(block, "text", None),
203
- }
204
- for block in response.content
205
- ],
206
- "stop_reason": response.stop_reason,
207
- "usage": {
208
- "input_tokens": response.usage.input_tokens,
209
- "output_tokens": response.usage.output_tokens,
210
- },
211
- },
257
+ output=output_data,
258
+ metadata={"provider": "anthropic", "model": model},
259
+ control_plane_results=control_plane_results,
260
+ )
261
+ set_last_receipt(receipt)
262
+ store_evidence(
263
+ receipt=receipt,
264
+ service_id=effective_service_id,
265
+ operation_type="completion",
266
+ input_data=input_data,
267
+ output_data=output_data,
268
+ control_plane_results=control_plane_results,
212
269
  metadata={"provider": "anthropic", "model": model},
270
+ debug=debug,
213
271
  )
214
272
  except Exception as e:
215
273
  if debug:
@@ -218,5 +276,12 @@ def attested_async_anthropic(
218
276
  return response
219
277
 
220
278
  client.messages.create = attested_create # type: ignore
221
-
222
279
  return client
280
+
281
+
282
+ __all__ = [
283
+ "attested_anthropic",
284
+ "get_last_receipt",
285
+ "get_evidence",
286
+ "GlacisBlockedError",
287
+ ]