glacis 0.1.4__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/__init__.py CHANGED
@@ -37,6 +37,13 @@ Streaming Example:
37
37
  ... })
38
38
  >>> await session.attest_chunk(input=audio_chunk, output=transcript)
39
39
  >>> receipt = await session.end(metadata={"duration": "00:05:23"})
40
+
41
+ Controls Example:
42
+ >>> from glacis.controls import ControlsRunner, PIIControl, JailbreakControl
43
+ >>> from glacis.config import load_config
44
+ >>> cfg = load_config() # Loads glacis.yaml
45
+ >>> runner = ControlsRunner(cfg.controls)
46
+ >>> results = runner.run("Patient SSN: 123-45-6789")
40
47
  """
41
48
 
42
49
  from glacis.client import AsyncGlacis, Glacis, GlacisMode
@@ -44,26 +51,56 @@ from glacis.crypto import canonical_json, hash_payload
44
51
  from glacis.models import (
45
52
  AttestInput,
46
53
  AttestReceipt,
54
+ ControlExecution,
55
+ ControlPlaneAttestation,
56
+ ControlStatus,
57
+ ControlType,
58
+ Determination,
59
+ GlacisApiError,
47
60
  GlacisConfig,
61
+ JailbreakSummary,
48
62
  LogEntry,
49
63
  LogQueryParams,
50
64
  LogQueryResult,
51
65
  MerkleInclusionProof,
66
+ ModelInfo,
52
67
  OfflineAttestReceipt,
53
68
  OfflineVerifyResult,
69
+ PiiPhiSummary,
70
+ PolicyContext,
71
+ PolicyScope,
72
+ SafetyScores,
73
+ SamplingDecision,
74
+ SamplingMetadata,
54
75
  SignedTreeHead,
55
76
  VerifyResult,
56
77
  )
57
78
  from glacis.storage import ReceiptStorage
58
79
  from glacis.streaming import SessionContext, SessionReceipt, StreamingSession
59
80
 
60
- __version__ = "0.2.0"
81
+ # Controls module (optional dependencies for individual controls)
82
+ try:
83
+ from glacis.controls import ( # noqa: F401
84
+ BaseControl,
85
+ ControlResult,
86
+ ControlsRunner,
87
+ JailbreakControl,
88
+ PIIControl,
89
+ )
90
+
91
+ _CONTROLS_AVAILABLE = True
92
+ except ImportError:
93
+ _CONTROLS_AVAILABLE = False
94
+
95
+ __version__ = "0.3.0"
61
96
 
62
97
  __all__ = [
63
98
  # Main classes
64
99
  "Glacis",
65
100
  "AsyncGlacis",
66
101
  "GlacisMode",
102
+ # Exceptions
103
+ "GlacisApiError",
67
104
  # Streaming
68
105
  "StreamingSession",
69
106
  "SessionContext",
@@ -82,7 +119,31 @@ __all__ = [
82
119
  "LogEntry",
83
120
  "MerkleInclusionProof",
84
121
  "SignedTreeHead",
122
+ # Control Plane
123
+ "ControlPlaneAttestation",
124
+ "PolicyContext",
125
+ "PolicyScope",
126
+ "ModelInfo",
127
+ "Determination",
128
+ "ControlExecution",
129
+ "ControlType",
130
+ "ControlStatus",
131
+ "SafetyScores",
132
+ "PiiPhiSummary",
133
+ "JailbreakSummary",
134
+ "SamplingMetadata",
135
+ "SamplingDecision",
85
136
  # Crypto utilities
86
137
  "canonical_json",
87
138
  "hash_payload",
88
139
  ]
140
+
141
+ # Add controls exports if available
142
+ if _CONTROLS_AVAILABLE:
143
+ __all__.extend([
144
+ "BaseControl",
145
+ "ControlResult",
146
+ "ControlsRunner",
147
+ "PIIControl",
148
+ "JailbreakControl",
149
+ ])
glacis/__main__.py CHANGED
@@ -5,86 +5,7 @@ Usage:
5
5
  python -m glacis verify <receipt.json>
6
6
  """
7
7
 
8
- import argparse
9
- import json
10
- import sys
11
- from pathlib import Path
12
- from typing import Any, Union
13
-
14
- from glacis import Glacis
15
- from glacis.models import AttestReceipt, OfflineAttestReceipt, OfflineVerifyResult, VerifyResult
16
-
17
-
18
- def verify_command(args: argparse.Namespace) -> None:
19
- """Verify a receipt file."""
20
- receipt_path = Path(args.receipt)
21
-
22
- if not receipt_path.exists():
23
- print(f"Error: File not found: {receipt_path}", file=sys.stderr)
24
- sys.exit(1)
25
-
26
- try:
27
- with open(receipt_path) as f:
28
- data: dict[str, Any] = json.load(f)
29
- except json.JSONDecodeError as e:
30
- print(f"Error: Invalid JSON: {e}", file=sys.stderr)
31
- sys.exit(1)
32
-
33
- # Determine receipt type and verify
34
- receipt: Union[AttestReceipt, OfflineAttestReceipt]
35
- result: Union[VerifyResult, OfflineVerifyResult]
36
-
37
- if data.get("attestation_id", "").startswith("oatt_"):
38
- offline_receipt = OfflineAttestReceipt(**data)
39
- # For verification, we need a signing_seed but it's not used
40
- # since we verify using the public key from the receipt
41
- dummy_seed = bytes(32) # All zeros - only used to satisfy constructor
42
- glacis = Glacis(mode="offline", signing_seed=dummy_seed)
43
- result = glacis.verify(offline_receipt)
44
- receipt = offline_receipt
45
- else:
46
- online_receipt = AttestReceipt(**data)
47
- # Online verification doesn't require API key for public receipts
48
- glacis = Glacis(api_key="verify_only")
49
- result = glacis.verify(online_receipt)
50
- receipt = online_receipt
51
-
52
- # Output
53
- print(f"Receipt: {receipt.attestation_id}")
54
- print(f"Type: {'Offline' if isinstance(receipt, OfflineAttestReceipt) else 'Online'}")
55
- print()
56
-
57
- if result.valid:
58
- print("Status: VALID")
59
- # Get signature validity based on result type
60
- if isinstance(result, OfflineVerifyResult):
61
- sig_valid = result.signature_valid
62
- else:
63
- sig_valid = result.verification.signature_valid
64
- print(f" Signature: {'PASS' if sig_valid else 'FAIL'}")
65
- if isinstance(result, VerifyResult):
66
- print(f" Merkle proof: {'PASS' if result.verification.proof_valid else 'FAIL'}")
67
- else:
68
- print("Status: INVALID")
69
- if result.error:
70
- print(f" Error: {result.error}")
71
- sys.exit(1)
72
-
73
-
74
- def main() -> None:
75
- parser = argparse.ArgumentParser(
76
- prog="glacis", description="Glacis CLI - Cryptographic attestation for AI systems"
77
- )
78
- subparsers = parser.add_subparsers(dest="command", required=True)
79
-
80
- # verify command
81
- verify_parser = subparsers.add_parser("verify", help="Verify a receipt")
82
- verify_parser.add_argument("receipt", help="Path to receipt JSON file")
83
- verify_parser.set_defaults(func=verify_command)
84
-
85
- args = parser.parse_args()
86
- args.func(args)
87
-
8
+ from glacis.verify import main
88
9
 
89
10
  if __name__ == "__main__":
90
11
  main()
glacis/client.py CHANGED
@@ -8,7 +8,7 @@ your infrastructure.
8
8
 
9
9
  Supports two modes:
10
10
  - Online (default): Sends attestations to api.glacis.io for witnessing
11
- - Offline: Signs attestations locally using Ed25519 via WASM
11
+ - Offline: Signs attestations locally using Ed25519
12
12
 
13
13
  Example (online):
14
14
  >>> from glacis import Glacis
@@ -43,6 +43,7 @@ import httpx
43
43
  from glacis.crypto import hash_payload
44
44
  from glacis.models import (
45
45
  AttestReceipt,
46
+ ControlPlaneAttestation,
46
47
  GlacisApiError,
47
48
  GlacisRateLimitError,
48
49
  LogQueryResult,
@@ -53,8 +54,8 @@ from glacis.models import (
53
54
  )
54
55
 
55
56
  if TYPE_CHECKING:
57
+ from glacis.crypto import Ed25519Runtime
56
58
  from glacis.storage import ReceiptStorage
57
- from glacis.wasm_runtime import WasmRuntime
58
59
 
59
60
 
60
61
  class GlacisMode(str, Enum):
@@ -136,7 +137,7 @@ class Glacis:
136
137
  self._storage: Optional["ReceiptStorage"] = None
137
138
  self._signing_seed: Optional[bytes] = None
138
139
  self._public_key: Optional[str] = None
139
- self._wasm_runtime: Optional["WasmRuntime"] = None
140
+ self._ed25519: Optional["Ed25519Runtime"] = None
140
141
  else:
141
142
  # Offline mode
142
143
  if not signing_seed:
@@ -148,11 +149,11 @@ class Glacis:
148
149
  self._signing_seed = signing_seed
149
150
  self._client = None # No HTTP client needed
150
151
 
151
- # Initialize WASM runtime and derive public key
152
- from glacis.wasm_runtime import WasmRuntime
152
+ # Initialize Ed25519 runtime and derive public key
153
+ from glacis.crypto import get_ed25519_runtime
153
154
 
154
- self._wasm_runtime = WasmRuntime.get_instance()
155
- self._public_key = self._wasm_runtime.get_public_key_hex(signing_seed)
155
+ self._ed25519 = get_ed25519_runtime()
156
+ self._public_key = self._ed25519.get_public_key_hex(signing_seed)
156
157
 
157
158
  # Initialize storage
158
159
  from glacis.storage import ReceiptStorage
@@ -183,20 +184,23 @@ class Glacis:
183
184
  input: Any,
184
185
  output: Any,
185
186
  metadata: Optional[dict[str, str]] = None,
187
+ control_plane_results: Optional[ControlPlaneAttestation] = None,
186
188
  ) -> Union[AttestReceipt, OfflineAttestReceipt]:
187
189
  """
188
190
  Attest an AI operation.
189
191
 
190
- The input and output are hashed locally using RFC 8785 canonical JSON + SHA-256.
191
- In online mode, the hash is sent to the server for witnessing.
192
- In offline mode, the attestation is signed locally.
192
+ The input, output, and control_plane_results are hashed locally using RFC 8785
193
+ canonical JSON + SHA-256. Only the hash is sent to the server - the actual
194
+ data never leaves your infrastructure (zero egress).
193
195
 
194
196
  Args:
195
197
  service_id: Service identifier (e.g., "my-ai-service")
196
198
  operation_type: Type of operation (inference, embedding, completion, classification)
197
199
  input: Input data (hashed locally, never sent)
198
200
  output: Output data (hashed locally, never sent)
199
- metadata: Optional metadata (sent to server in online mode)
201
+ metadata: Optional metadata (stored locally for evidence)
202
+ control_plane_results: Optional control plane attestation
203
+ (hashed locally, cryptographically bound)
200
204
 
201
205
  Returns:
202
206
  AttestReceipt (online) or OfflineAttestReceipt (offline)
@@ -205,32 +209,47 @@ class Glacis:
205
209
  GlacisApiError: On API errors (online mode)
206
210
  GlacisRateLimitError: When rate limited (online mode)
207
211
  """
208
- payload_hash = self.hash({"input": input, "output": output})
212
+ # Build payload to hash - includes control_plane_results for cryptographic binding
213
+ payload_to_hash: dict[str, Any] = {"input": input, "output": output}
214
+ if control_plane_results:
215
+ payload_to_hash["control_plane_results"] = (
216
+ control_plane_results.model_dump(by_alias=True)
217
+ )
218
+ payload_hash = self.hash(payload_to_hash)
209
219
 
210
220
  if self.mode == GlacisMode.OFFLINE:
211
221
  return self._attest_offline(
212
- service_id, operation_type, payload_hash, input, output, metadata
222
+ service_id, operation_type, payload_hash,
223
+ input, output, metadata, control_plane_results,
213
224
  )
214
225
 
215
- return self._attest_online(service_id, operation_type, payload_hash, metadata)
226
+ return self._attest_online(service_id, operation_type, payload_hash, control_plane_results)
216
227
 
217
228
  def _attest_online(
218
229
  self,
219
230
  service_id: str,
220
231
  operation_type: str,
221
232
  payload_hash: str,
222
- metadata: Optional[dict[str, str]],
233
+ control_plane_results: Optional[ControlPlaneAttestation] = None,
223
234
  ) -> AttestReceipt:
224
- """Create a server-witnessed attestation."""
235
+ """Create a server-witnessed attestation.
236
+
237
+ The server is a pure witness service - it only receives the hash and
238
+ adds it to the Merkle tree. All rich metadata (input, output, control_plane_results)
239
+ stays in the customer's infrastructure for zero-egress compliance.
240
+
241
+ The control_plane_results is cryptographically bound via the payload_hash (which
242
+ includes control_plane_results in its computation).
243
+ """
225
244
  self._debug(f"Attesting (online): service_id={service_id}, hash={payload_hash[:16]}...")
226
245
 
246
+ # Only send what the server needs - hash goes into Merkle tree
247
+ # metadata and control_plane_results are NOT sent (zero egress - stored locally)
227
248
  body: dict[str, Any] = {
228
249
  "serviceId": service_id,
229
250
  "operationType": operation_type,
230
251
  "payloadHash": payload_hash,
231
252
  }
232
- if metadata:
233
- body["metadata"] = metadata
234
253
 
235
254
  response = self._request_with_retry(
236
255
  "POST",
@@ -240,6 +259,8 @@ class Glacis:
240
259
  )
241
260
 
242
261
  receipt = AttestReceipt.model_validate(response)
262
+ # Attach control_plane_results locally for evidence (not sent to server)
263
+ receipt.control_plane_results = control_plane_results
243
264
  self._debug(f"Attestation successful: {receipt.attestation_id}")
244
265
  return receipt
245
266
 
@@ -251,6 +272,7 @@ class Glacis:
251
272
  input: Any,
252
273
  output: Any,
253
274
  metadata: Optional[dict[str, str]],
275
+ control_plane_results: Optional[ControlPlaneAttestation] = None,
254
276
  ) -> OfflineAttestReceipt:
255
277
  """Create a locally-signed attestation."""
256
278
  self._debug(f"Attesting (offline): service_id={service_id}, hash={payload_hash[:16]}...")
@@ -259,8 +281,8 @@ class Glacis:
259
281
  timestamp = datetime.utcnow().isoformat() + "Z"
260
282
  timestamp_ms = int(datetime.utcnow().timestamp() * 1000)
261
283
 
262
- # Build attestation payload
263
- attestation_payload = {
284
+ # Build attestation payload (this is what gets signed)
285
+ attestation_payload: dict[str, Any] = {
264
286
  "version": 1,
265
287
  "serviceId": service_id,
266
288
  "operationType": operation_type,
@@ -269,15 +291,21 @@ class Glacis:
269
291
  "mode": "offline",
270
292
  }
271
293
 
294
+ # Include control plane results in signed payload if provided
295
+ if control_plane_results:
296
+ attestation_payload["controlPlaneResults"] = (
297
+ control_plane_results.model_dump(by_alias=True)
298
+ )
299
+
272
300
  # Sign using WASM
273
301
  attestation_json = json.dumps(
274
302
  attestation_payload, separators=(",", ":"), sort_keys=True
275
303
  )
276
- assert self._wasm_runtime is not None
304
+ assert self._ed25519 is not None
277
305
  assert self._signing_seed is not None
278
306
  assert self._public_key is not None
279
307
 
280
- signed_json = self._wasm_runtime.sign_attestation_json(
308
+ signed_json = self._ed25519.sign_attestation_json(
281
309
  self._signing_seed, attestation_json
282
310
  )
283
311
  signed = json.loads(signed_json)
@@ -290,6 +318,7 @@ class Glacis:
290
318
  payload_hash=payload_hash,
291
319
  signature=signed["signature"],
292
320
  public_key=self._public_key,
321
+ control_plane_results=control_plane_results,
293
322
  )
294
323
 
295
324
  # Store in SQLite
@@ -334,7 +363,7 @@ class Glacis:
334
363
  # Online attestation ID
335
364
  return self._verify_online(receipt)
336
365
  elif isinstance(receipt, AttestReceipt):
337
- return self._verify_online(receipt.attestation_id)
366
+ return self._verify_online(receipt.attestation_hash)
338
367
  else:
339
368
  raise TypeError(f"Invalid receipt type: {type(receipt)}")
340
369
 
@@ -359,13 +388,13 @@ class Glacis:
359
388
  # which we don't currently do. A more robust solution would store the
360
389
  # signed payload or timestampMs for later verification.
361
390
 
362
- # Use WASM to verify if we have the runtime
363
- if self._wasm_runtime and self._signing_seed:
391
+ # Use Ed25519 to verify if we have the runtime
392
+ if self._ed25519 and self._signing_seed:
364
393
  # We can at least verify the public key matches our seed
365
- derived_pubkey = self._wasm_runtime.get_public_key_hex(self._signing_seed)
394
+ derived_pubkey = self._ed25519.get_public_key_hex(self._signing_seed)
366
395
  signature_valid = derived_pubkey == receipt.public_key
367
396
  else:
368
- # Without the WASM runtime, we can't fully verify
397
+ # Without the Ed25519 runtime, we can't fully verify
369
398
  # But we can check if the receipt is in our storage
370
399
  signature_valid = True # Trusted from local storage
371
400
 
@@ -430,9 +459,9 @@ class Glacis:
430
459
  """
431
460
  params: dict[str, Any] = {}
432
461
  if org_id:
433
- params["org_id"] = org_id
462
+ params["orgId"] = org_id
434
463
  if service_id:
435
- params["service_id"] = service_id
464
+ params["serviceId"] = service_id
436
465
  if start:
437
466
  params["start"] = start
438
467
  if end:
@@ -702,9 +731,9 @@ class AsyncGlacis:
702
731
  """
703
732
  params: dict[str, Any] = {}
704
733
  if org_id:
705
- params["org_id"] = org_id
734
+ params["orgId"] = org_id
706
735
  if service_id:
707
- params["service_id"] = service_id
736
+ params["serviceId"] = service_id
708
737
  if start:
709
738
  params["start"] = start
710
739
  if end:
glacis/config.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ GLACIS Configuration Module
3
+
4
+ Provides configuration models and loading for glacis.yaml config files.
5
+ The config file allows toggling controls (PII/PHI redaction, etc.) on/off.
6
+
7
+ Example glacis.yaml:
8
+ version: "1.0"
9
+ policy:
10
+ id: "hipaa-safe-harbor"
11
+ tenant_id: "my-org"
12
+ controls:
13
+ pii_phi:
14
+ enabled: true
15
+ backend: "presidio"
16
+ mode: "fast"
17
+ jailbreak:
18
+ enabled: true
19
+ backend: "prompt_guard_22m"
20
+ threshold: 0.5
21
+ action: "flag"
22
+ attestation:
23
+ offline: true
24
+ service_id: "my-service"
25
+
26
+ Usage:
27
+ from glacis.config import load_config
28
+
29
+ # Auto-load from ./glacis.yaml
30
+ config = load_config()
31
+
32
+ # Or explicit path
33
+ config = load_config("path/to/glacis.yaml")
34
+ """
35
+
36
+ from pathlib import Path
37
+ from typing import Literal, Optional
38
+
39
+ from pydantic import BaseModel, Field
40
+
41
+
42
+ class PiiPhiConfig(BaseModel):
43
+ """PII/PHI redaction control configuration."""
44
+
45
+ enabled: bool = Field(default=False, description="Enable PII/PHI redaction")
46
+ backend: str = Field(
47
+ default="presidio",
48
+ description="Backend model identifier (e.g., 'presidio')",
49
+ )
50
+ mode: Literal["fast", "full"] = Field(
51
+ default="fast",
52
+ description="Redaction mode: 'fast' (regex-only) or 'full' (regex + NER)",
53
+ )
54
+
55
+
56
+ class JailbreakConfig(BaseModel):
57
+ """Jailbreak/prompt injection detection configuration."""
58
+
59
+ enabled: bool = Field(default=False, description="Enable jailbreak detection")
60
+ backend: str = Field(
61
+ default="prompt_guard_22m",
62
+ description="Backend model: 'prompt_guard_22m' or 'prompt_guard_86m'",
63
+ )
64
+ threshold: float = Field(
65
+ default=0.5,
66
+ ge=0.0,
67
+ le=1.0,
68
+ description="Classification threshold (0-1)",
69
+ )
70
+ action: Literal["block", "flag", "log"] = Field(
71
+ default="flag",
72
+ description="Action when jailbreak detected: 'block', 'flag', or 'log'",
73
+ )
74
+
75
+
76
+ class ControlsConfig(BaseModel):
77
+ """Configuration for all controls."""
78
+
79
+ pii_phi: PiiPhiConfig = Field(default_factory=PiiPhiConfig)
80
+ jailbreak: JailbreakConfig = Field(default_factory=JailbreakConfig)
81
+
82
+
83
+ class PolicyConfig(BaseModel):
84
+ """Policy metadata included in attestations."""
85
+
86
+ id: str = Field(default="default", description="Policy identifier")
87
+ version: str = Field(default="1.0", description="Policy version")
88
+ tenant_id: str = Field(default="default", description="Tenant identifier")
89
+
90
+
91
+ class AttestationConfig(BaseModel):
92
+ """Attestation settings."""
93
+
94
+ offline: bool = Field(default=True, description="Use offline mode (local signing)")
95
+ service_id: str = Field(default="openai", description="Service identifier")
96
+
97
+
98
+ class GlacisConfig(BaseModel):
99
+ """Root configuration model for GLACIS."""
100
+
101
+ version: str = Field(default="1.0", description="Config schema version")
102
+ policy: PolicyConfig = Field(default_factory=PolicyConfig)
103
+ controls: ControlsConfig = Field(default_factory=ControlsConfig)
104
+ attestation: AttestationConfig = Field(default_factory=AttestationConfig)
105
+
106
+
107
+ def load_config(path: Optional[str] = None) -> GlacisConfig:
108
+ """
109
+ Load configuration from a YAML file.
110
+
111
+ Args:
112
+ path: Explicit path to config file. If None, looks for ./glacis.yaml
113
+
114
+ Returns:
115
+ GlacisConfig instance (defaults if no config file found)
116
+
117
+ Example:
118
+ >>> config = load_config() # Auto-load ./glacis.yaml
119
+ >>> config = load_config("configs/production.yaml") # Explicit path
120
+ """
121
+ if path:
122
+ config_path = Path(path)
123
+ else:
124
+ config_path = Path("glacis.yaml")
125
+
126
+ if config_path.exists():
127
+ try:
128
+ import yaml
129
+ except ImportError:
130
+ raise ImportError(
131
+ "pyyaml is required for config file support. "
132
+ "Install with: pip install pyyaml"
133
+ )
134
+
135
+ with open(config_path) as f:
136
+ data = yaml.safe_load(f)
137
+
138
+ if data:
139
+ return GlacisConfig(**data)
140
+
141
+ return GlacisConfig()