grantex-gemma 0.1.0__tar.gz

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.
@@ -0,0 +1,52 @@
1
+ # OS
2
+ .DS_Store
3
+ Thumbs.db
4
+
5
+ # Editors
6
+ .vscode/
7
+ .idea/
8
+ *.swp
9
+ *.swo
10
+
11
+ # Environment
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+ # Claude Code
17
+ .claude/
18
+
19
+ # Node
20
+ node_modules/
21
+
22
+ # Python
23
+ __pycache__/
24
+ *.pyc
25
+ *.pyo
26
+
27
+ # Build outputs
28
+ dist/
29
+ build/
30
+
31
+ # Firebase
32
+ .firebase/
33
+ web/dashboard/
34
+
35
+ # Generated PH gallery screenshots
36
+ web/ph-gallery/
37
+
38
+ # Marketing content (local only)
39
+ docs/marketing/
40
+ LAUNCH_PLAYBOOK.md
41
+ LAUNCH_POSTS.md
42
+ DEVTO_ARTICLE.md
43
+
44
+ # Recordings (local only)
45
+ recordings/
46
+
47
+ # Coverage & caches
48
+ coverage/
49
+ .cache/
50
+ .mypy_cache/
51
+ .pytest_cache/
52
+ *.tsbuildinfo
@@ -0,0 +1,499 @@
1
+ Metadata-Version: 2.4
2
+ Name: grantex-gemma
3
+ Version: 0.1.0
4
+ Summary: Offline authorization for Gemma 4 on-device AI agents
5
+ Project-URL: Homepage, https://grantex.dev
6
+ Project-URL: Documentation, https://docs.grantex.dev
7
+ Project-URL: Repository, https://github.com/mishrasanjeev/grantex
8
+ Project-URL: Issues, https://github.com/mishrasanjeev/grantex/issues
9
+ Author: Sanjeev Kumar
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,audit,authorization,gemma,grantex,hash-chain,jwt,offline-auth,on-device
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: cryptography>=41
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: pyjwt>=2.8
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.9; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: respx>=0.21; extra == 'dev'
33
+ Requires-Dist: ruff>=0.4; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # grantex-gemma
37
+
38
+ Offline authorization for Google Gemma on-device AI agents. Issue consent bundles online, verify grant tokens and enforce scopes entirely offline, and sync tamper-evident audit logs back to the Grantex cloud when connectivity returns.
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/grantex-gemma)](https://pypi.org/project/grantex-gemma/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/grantex-gemma)](https://pypi.org/project/grantex-gemma/)
42
+ [![License](https://img.shields.io/pypi/l/grantex-gemma)](https://github.com/mishrasanjeev/grantex/blob/main/LICENSE)
43
+ [![Downloads](https://img.shields.io/pypi/dm/grantex-gemma)](https://pypi.org/project/grantex-gemma/)
44
+
45
+ > **[Homepage](https://grantex.dev)** | **[Docs](https://docs.grantex.dev/integrations/gemma)** | **[API Reference](https://docs.grantex.dev/api-reference)** | **[GitHub](https://github.com/mishrasanjeev/grantex)** | **[Sign Up Free](https://grantex.dev/dashboard/signup)**
46
+
47
+ ## What is grantex-gemma?
48
+
49
+ When you run Gemma 4 on a Raspberry Pi, NVIDIA Jetson, or any server-side device, the model often needs to act on behalf of a user — reading contacts, sending messages, accessing calendars. But these devices go offline. WiFi drops, cellular is spotty, and edge deployments may only sync once a day.
50
+
51
+ **grantex-gemma** solves this with a three-phase offline authorization model:
52
+
53
+ 1. **Online** — While connected, your agent requests a *consent bundle* from the Grantex API. The bundle contains a signed grant token, a JWKS snapshot for offline verification, and an Ed25519 key pair for signing audit entries.
54
+
55
+ 2. **Offline** — The agent verifies the grant token locally (RS256 against the JWKS snapshot), enforces scopes, and logs every action to a tamper-evident, hash-chained audit file. No network required.
56
+
57
+ 3. **Sync** — When connectivity returns, the agent uploads the signed audit log to the Grantex cloud. The server verifies the hash chain, checks for revocations, and optionally issues a refreshed bundle.
58
+
59
+ Everything is cryptographically verifiable. Grant tokens are standard Grantex JWTs (RS256). Audit entries are Ed25519-signed and SHA-256 hash-chained. Bundles are encrypted at rest with AES-256-GCM.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install grantex-gemma
65
+ ```
66
+
67
+ With development dependencies:
68
+
69
+ ```bash
70
+ pip install grantex-gemma[dev]
71
+ ```
72
+
73
+ **Requirements:** Python 3.9+, [httpx](https://www.python-httpx.org/), [PyJWT](https://pyjwt.readthedocs.io/), [cryptography](https://cryptography.io/)
74
+
75
+ ## Quick Start
76
+
77
+ ```python
78
+ import asyncio
79
+ from grantex_gemma import (
80
+ create_consent_bundle,
81
+ create_offline_verifier,
82
+ create_offline_audit_log,
83
+ store_bundle,
84
+ load_bundle,
85
+ )
86
+
87
+ ENCRYPTION_KEY = "a1b2c3..." # 64-char hex string (256-bit key)
88
+
89
+ async def main():
90
+ # ── Phase 1: Online — Issue a consent bundle ────────────────────
91
+ bundle = await create_consent_bundle(
92
+ api_key="gx_dev_...",
93
+ agent_id="did:web:my-agent.example.com",
94
+ user_id="did:web:alice.example.com",
95
+ scopes=["read:contacts", "write:calendar"],
96
+ offline_ttl="72h", # Bundle valid for 72 hours offline
97
+ )
98
+
99
+ # Persist to disk with AES-256-GCM encryption
100
+ store_bundle(bundle, "/data/grantex/bundle.enc", ENCRYPTION_KEY)
101
+
102
+ # ── Phase 2: Offline — Verify tokens and log actions ────────────
103
+ bundle = load_bundle("/data/grantex/bundle.enc", ENCRYPTION_KEY)
104
+
105
+ verifier = create_offline_verifier(
106
+ bundle.jwks_snapshot,
107
+ require_scopes=["read:contacts"],
108
+ max_delegation_depth=2,
109
+ )
110
+
111
+ # Verify the grant token — pure local crypto, no HTTP call
112
+ grant = await verifier.verify(bundle.grant_token)
113
+ print(f"Authorized: {grant.agent_did} for {grant.scopes}")
114
+
115
+ # Create an append-only, hash-chained audit log
116
+ audit = create_offline_audit_log(
117
+ bundle.offline_audit_key,
118
+ log_path="/data/grantex/audit.jsonl",
119
+ )
120
+
121
+ # Log every action the agent performs
122
+ entry = await audit.append("read:contacts", grant, "success", {"count": 42})
123
+ print(f"Logged entry #{entry.seq}, hash: {entry.hash[:16]}...")
124
+
125
+ # ── Phase 3: Online — Sync audit log ────────────────────────────
126
+ result = await audit.sync(
127
+ endpoint=bundle.sync_endpoint,
128
+ api_key="gx_dev_...",
129
+ bundle_id=bundle.bundle_id,
130
+ )
131
+ print(f"Synced: {result.accepted} accepted, {result.rejected} rejected")
132
+
133
+ # If the grant was revoked while offline, stop the agent
134
+ if result.revocation_status == "revoked":
135
+ print("Grant revoked — halting agent")
136
+
137
+ asyncio.run(main())
138
+ ```
139
+
140
+ ## Architecture
141
+
142
+ ```
143
+ ┌──────────────────────────────────────────────────────────────┐
144
+ │ PHASE 1: ONLINE │
145
+ │ │
146
+ │ Device ──POST /v1/consent-bundles──▶ Grantex API │
147
+ │ │ │
148
+ │ store_bundle(AES-256-GCM) │
149
+ │ ▼ │
150
+ │ [encrypted file on disk] │
151
+ ├──────────────────────────────────────────────────────────────┤
152
+ │ PHASE 2: OFFLINE │
153
+ │ │
154
+ │ load_bundle() ──▶ OfflineVerifier.verify(token) │
155
+ │ │ RS256 + JWKS snapshot │
156
+ │ ▼ │
157
+ │ VerifiedGrant ──▶ AuditLog.append() │
158
+ │ Ed25519 + SHA-256 chain │
159
+ │ ▼ │
160
+ │ [audit.jsonl] │
161
+ ├──────────────────────────────────────────────────────────────┤
162
+ │ PHASE 3: SYNC │
163
+ │ │
164
+ │ AuditLog.sync() ──POST batches──▶ Grantex API │
165
+ │ ▼ │
166
+ │ SyncResult │
167
+ │ ├─ accepted / rejected │
168
+ │ ├─ revocation_status │
169
+ │ └─ new_bundle (optional) │
170
+ └──────────────────────────────────────────────────────────────┘
171
+ ```
172
+
173
+ ## API Reference
174
+
175
+ ### `create_offline_verifier(jwks_snapshot, ...) -> OfflineVerifier`
176
+
177
+ Create an offline JWT verifier for on-device use.
178
+
179
+ | Parameter | Type | Default | Description |
180
+ |-----------|------|---------|-------------|
181
+ | `jwks_snapshot` | `JWKSSnapshot` | required | Pre-fetched JWKS keys for RS256 verification |
182
+ | `clock_skew_seconds` | `int` | `30` | Allowable clock drift in seconds |
183
+ | `require_scopes` | `list[str] \| None` | `None` | Scopes that must be present on every token |
184
+ | `max_delegation_depth` | `int \| None` | `None` | Maximum delegation chain depth allowed |
185
+ | `on_scope_violation` | `str` | `"throw"` | `"throw"` raises `ScopeViolationError`, `"log"` warns |
186
+
187
+ Returns an `OfflineVerifier` with a single method:
188
+
189
+ - **`async verify(token: str) -> VerifiedGrant`** — Verify a JWT and return the decoded grant. Raises `OfflineVerificationError`, `TokenExpiredError`, or `ScopeViolationError`.
190
+
191
+ ### `create_consent_bundle(api_key, agent_id, user_id, scopes, ...) -> ConsentBundle`
192
+
193
+ Request a consent bundle from the Grantex API (requires network).
194
+
195
+ | Parameter | Type | Default | Description |
196
+ |-----------|------|---------|-------------|
197
+ | `api_key` | `str` | required | Grantex developer API key |
198
+ | `agent_id` | `str` | required | Agent DID or identifier |
199
+ | `user_id` | `str` | required | Principal (user) identifier |
200
+ | `scopes` | `list[str]` | required | Requested authorization scopes |
201
+ | `offline_ttl` | `str` | `"72h"` | How long the bundle is valid offline |
202
+ | `base_url` | `str` | `"https://api.grantex.dev"` | Grantex API base URL |
203
+
204
+ ### `store_bundle(bundle, path, encryption_key) -> None`
205
+
206
+ Encrypt and write a consent bundle to disk using AES-256-GCM.
207
+
208
+ | Parameter | Type | Description |
209
+ |-----------|------|-------------|
210
+ | `bundle` | `ConsentBundle` | The bundle to encrypt and store |
211
+ | `path` | `str` | File path for the encrypted bundle |
212
+ | `encryption_key` | `str` | Hex-encoded 256-bit key (64 hex characters) |
213
+
214
+ ### `load_bundle(path, encryption_key) -> ConsentBundle`
215
+
216
+ Load and decrypt a consent bundle from disk. Raises `BundleTamperedError` if decryption or integrity check fails. Raises `FileNotFoundError` if the file does not exist.
217
+
218
+ ### `create_offline_audit_log(signing_key, log_path, ...) -> OfflineAuditLog`
219
+
220
+ Create an append-only, Ed25519-signed, hash-chained audit log backed by a JSONL file.
221
+
222
+ | Parameter | Type | Default | Description |
223
+ |-----------|------|---------|-------------|
224
+ | `signing_key` | `OfflineAuditKey` | required | Ed25519 key pair for signing entries |
225
+ | `log_path` | `str` | required | Path to the JSONL log file |
226
+ | `max_size_mb` | `int` | `50` | Maximum log file size in MB before rotation |
227
+ | `rotate_on_size` | `bool` | `True` | Whether to rotate when size limit is reached |
228
+
229
+ **`OfflineAuditLog` methods:**
230
+
231
+ - **`async append(action, grant, result, metadata=None) -> SignedAuditEntry`** — Append a signed, hash-chained entry.
232
+ - **`async sync(endpoint, api_key, bundle_id, batch_size=100) -> SyncResult`** — Upload audit entries to the Grantex cloud in batches.
233
+
234
+ ### `enforce_scopes(grant_scopes, required_scopes) -> None`
235
+
236
+ Ensure all required scopes are present. Raises `ScopeViolationError` if any are missing.
237
+
238
+ ```python
239
+ from grantex_gemma import enforce_scopes
240
+ enforce_scopes(grant.scopes, ["read:contacts", "write:calendar"])
241
+ ```
242
+
243
+ ### `has_scope(grant_scopes, scope) -> bool`
244
+
245
+ Check if a single scope is present in the grant.
246
+
247
+ ```python
248
+ from grantex_gemma import has_scope
249
+ if has_scope(grant.scopes, "write:calendar"):
250
+ ...
251
+ ```
252
+
253
+ ### `compute_entry_hash(entry) -> str`
254
+
255
+ Compute the SHA-256 hash of an audit entry's content fields (`seq`, `timestamp`, `action`, `agent_did`, `grant_id`, `scopes`, `result`, `metadata`, `prev_hash`).
256
+
257
+ ### `verify_chain(entries) -> tuple[bool, int | None]`
258
+
259
+ Verify the integrity of a hash chain. Returns `(True, None)` if valid, or `(False, index)` where `index` is the first broken entry.
260
+
261
+ ```python
262
+ from grantex_gemma import verify_chain
263
+ valid, broken_at = verify_chain(entries)
264
+ if not valid:
265
+ print(f"Chain broken at entry {broken_at}")
266
+ ```
267
+
268
+ ## Type Definitions
269
+
270
+ All types are Python `dataclass` instances importable from `grantex_gemma`:
271
+
272
+ ### `JWKSSnapshot`
273
+
274
+ ```python
275
+ @dataclass
276
+ class JWKSSnapshot:
277
+ keys: list[dict[str, Any]] # JWK key objects
278
+ fetched_at: str # ISO 8601 timestamp
279
+ valid_until: str # ISO 8601 expiry
280
+ ```
281
+
282
+ ### `OfflineAuditKey`
283
+
284
+ ```python
285
+ @dataclass
286
+ class OfflineAuditKey:
287
+ public_key: str # PEM-encoded Ed25519 public key
288
+ private_key: str # PEM-encoded Ed25519 private key
289
+ algorithm: str # "Ed25519"
290
+ ```
291
+
292
+ ### `ConsentBundle`
293
+
294
+ ```python
295
+ @dataclass
296
+ class ConsentBundle:
297
+ bundle_id: str # Unique bundle identifier
298
+ grant_token: str # RS256-signed Grantex JWT
299
+ jwks_snapshot: JWKSSnapshot # Keys for offline verification
300
+ offline_audit_key: OfflineAuditKey # Ed25519 key pair for signing
301
+ checkpoint_at: int # Unix timestamp for next sync
302
+ sync_endpoint: str # URL for audit log upload
303
+ offline_expires_at: str # ISO 8601 offline expiry
304
+ ```
305
+
306
+ ### `VerifiedGrant`
307
+
308
+ ```python
309
+ @dataclass
310
+ class VerifiedGrant:
311
+ agent_did: str # Agent DID (from "agt" claim)
312
+ principal_did: str # User DID (from "sub" claim)
313
+ scopes: list[str] # Authorized scopes (from "scp" claim)
314
+ expires_at: datetime # Token expiry (from "exp" claim)
315
+ jti: str # Token ID (from "jti" claim)
316
+ grant_id: str # Grant ID (from "grnt" or "jti" claim)
317
+ depth: int # Delegation depth (0 = root grant)
318
+ ```
319
+
320
+ ### `SignedAuditEntry`
321
+
322
+ ```python
323
+ @dataclass
324
+ class SignedAuditEntry:
325
+ seq: int # Monotonic sequence number
326
+ timestamp: str # ISO 8601 timestamp
327
+ action: str # Action performed
328
+ agent_did: str # Agent that performed the action
329
+ grant_id: str # Grant that authorized it
330
+ scopes: list[str] # Scopes on the grant
331
+ result: str # "success", "denied", etc.
332
+ metadata: dict[str, Any] # Arbitrary context
333
+ prev_hash: str # SHA-256 hash of previous entry
334
+ hash: str # SHA-256 hash of this entry
335
+ signature: str # Ed25519 signature (base64url)
336
+ ```
337
+
338
+ ### `SyncResult`
339
+
340
+ ```python
341
+ @dataclass
342
+ class SyncResult:
343
+ accepted: int # Entries accepted by server
344
+ rejected: int # Entries rejected by server
345
+ revocation_status: str # "active" or "revoked"
346
+ new_bundle: ConsentBundle | None # Refreshed bundle (if issued)
347
+ ```
348
+
349
+ ## Error Classes
350
+
351
+ All exceptions inherit from `GrantexGemmaError`:
352
+
353
+ | Exception | Raised when |
354
+ |-----------|-------------|
355
+ | `GrantexGemmaError` | Base class for all grantex-gemma errors |
356
+ | `OfflineVerificationError` | JWT verification fails (bad signature, missing claims, unsupported algorithm) |
357
+ | `ScopeViolationError` | A required scope is missing from the grant |
358
+ | `TokenExpiredError` | The grant token has expired (past `exp` + clock skew) |
359
+ | `BundleTamperedError` | AES-256-GCM decryption fails — bundle was modified or wrong key |
360
+ | `GrantexAuthError` | API returns 401/403 or a network error during bundle creation/sync |
361
+ | `HashChainError` | Hash chain integrity verification fails, or sync returns an error |
362
+
363
+ `GrantexAuthError` includes a `status_code` property for HTTP error codes.
364
+
365
+ ### Catching errors in practice
366
+
367
+ ```python
368
+ from grantex_gemma import (
369
+ GrantexGemmaError,
370
+ OfflineVerificationError,
371
+ TokenExpiredError,
372
+ ScopeViolationError,
373
+ BundleTamperedError,
374
+ )
375
+
376
+ try:
377
+ bundle = load_bundle("/data/bundle.enc", encryption_key)
378
+ grant = await verifier.verify(bundle.grant_token)
379
+ enforce_scopes(grant.scopes, ["write:calendar"])
380
+ except BundleTamperedError:
381
+ # Bundle corrupted or wrong encryption key — re-fetch when online
382
+ pass
383
+ except TokenExpiredError:
384
+ # Grant expired — request a new consent bundle
385
+ pass
386
+ except ScopeViolationError:
387
+ # Agent tried to exceed its permissions
388
+ await audit.append("write:calendar", grant, "denied")
389
+ except OfflineVerificationError:
390
+ # Bad signature, missing claims, etc.
391
+ pass
392
+ except GrantexGemmaError:
393
+ # Catch-all for any grantex-gemma error
394
+ pass
395
+ ```
396
+
397
+ ## Security
398
+
399
+ - **RS256 only** — The verifier rejects `alg: "none"` and `alg: "HS256"` tokens. Only RS256 with pre-fetched public keys is accepted.
400
+ - **AES-256-GCM** — Consent bundles are encrypted at rest. Tampering is detected by GCM authentication.
401
+ - **Ed25519 audit signatures** — Every audit entry is signed with the bundle's Ed25519 private key. Signatures are verified server-side during sync.
402
+ - **SHA-256 hash chain** — Each audit entry's hash covers the previous entry's hash, forming a tamper-evident chain. Breaking one entry invalidates all subsequent entries.
403
+ - **No secrets in tokens** — Grant tokens are standard JWTs verified against public keys. No shared secrets.
404
+ - **Clock skew tolerance** — Configurable tolerance (default 30s) prevents false rejections on devices with imprecise clocks.
405
+ - **Delegation depth limits** — Prevent unbounded delegation chains with `max_delegation_depth`.
406
+
407
+ ## Platform Compatibility
408
+
409
+ `grantex-gemma` runs anywhere Python 3.9+ is available:
410
+
411
+ | Platform | Python | Avg verify time | Notes |
412
+ |----------|--------|-----------------|-------|
413
+ | Raspberry Pi 5 | 3.9+ | 3.2 ms | Tested on Raspberry Pi OS (64-bit) |
414
+ | NVIDIA Jetson | 3.9+ | 1.1 ms | Orin Nano / AGX Orin |
415
+ | Linux server | 3.9+ | < 1 ms | x86_64, tested on Ubuntu 22.04+ |
416
+ | macOS | 3.9+ | < 1 ms | Apple Silicon and Intel |
417
+ | Windows | 3.9+ | < 1 ms | Windows 10/11, native and WSL |
418
+
419
+ Verification is pure CPU (RSA signature check) — no GPU required. The bottleneck on constrained devices is the `cryptography` library's RSA implementation, which is written in Rust/C and well-optimized.
420
+
421
+ ## Testing
422
+
423
+ ```bash
424
+ pip install -e ".[dev]"
425
+ pytest
426
+ ```
427
+
428
+ 41 tests covering offline verification, consent bundle creation, hash chain integrity, audit log operations, and security edge cases. Uses `pytest-asyncio` for async support and `respx` for HTTP mocking.
429
+
430
+ ```bash
431
+ pytest --cov=grantex_gemma --cov-report=term-missing # coverage
432
+ mypy src/grantex_gemma # type checking
433
+ ```
434
+
435
+ ## Examples
436
+
437
+ ### Raspberry Pi agent
438
+
439
+ A complete example running a Gemma 4 agent on a Raspberry Pi with offline authorization:
440
+
441
+ ```
442
+ examples/gemma-raspberry-pi/
443
+ setup_bundle.py # Phase 1: Create and store a consent bundle
444
+ agent.py # Phase 2: Offline agent with verification and audit
445
+ sync_audit.py # Phase 3: Sync audit log when back online
446
+ verify_audit.py # Verify hash chain integrity of the audit log
447
+ ```
448
+
449
+ See the [Raspberry Pi example README](https://github.com/mishrasanjeev/grantex/tree/main/examples/gemma-raspberry-pi) for setup instructions.
450
+
451
+ ## Troubleshooting
452
+
453
+ ### `BundleTamperedError` when loading a bundle
454
+
455
+ The encryption key does not match the key used to store the bundle, or the file was modified. Use the same 64-character hex key for both `store_bundle` and `load_bundle`.
456
+
457
+ ### `OfflineVerificationError: No RSA keys available`
458
+
459
+ The JWKS snapshot contains no RSA keys. This usually means the bundle was created with a test/mock API that returned an empty key set. Create a new bundle against the real Grantex API.
460
+
461
+ ### `TokenExpiredError` after being offline too long
462
+
463
+ The grant token's `exp` claim has passed. Consent bundles have a limited offline TTL (default 72 hours). Re-create the bundle when connectivity is available, or request a longer `offline_ttl`.
464
+
465
+ ### `ScopeViolationError` on verify
466
+
467
+ The grant token does not include the scopes specified in `require_scopes`. Either request the correct scopes when creating the bundle, or set `on_scope_violation="log"` to downgrade to a warning.
468
+
469
+ ### `HashChainError` during sync
470
+
471
+ The server detected a gap or inconsistency in the hash chain. This happens if log entries were manually edited or the file was corrupted. Use `verify_chain()` locally to find the broken entry.
472
+
473
+ ### `cryptography` fails to install on Raspberry Pi
474
+
475
+ ```bash
476
+ sudo apt-get install -y build-essential libssl-dev libffi-dev python3-dev
477
+ pip install --prefer-binary cryptography
478
+ ```
479
+
480
+ ## Related Packages
481
+
482
+ | Package | Description | Install |
483
+ |---------|-------------|---------|
484
+ | [`grantex`](https://pypi.org/project/grantex/) | Python SDK (full API client) | `pip install grantex` |
485
+ | [`grantex-adk`](https://pypi.org/project/grantex-adk/) | Google ADK integration | `pip install grantex-adk` |
486
+ | [`@grantex/gemma`](https://www.npmjs.com/package/@grantex/gemma) | TypeScript version of this package | `npm install @grantex/gemma` |
487
+
488
+ ## Contributing
489
+
490
+ ```bash
491
+ git clone https://github.com/mishrasanjeev/grantex.git
492
+ cd grantex/packages/gemma-py
493
+ pip install -e ".[dev]"
494
+ pytest
495
+ ```
496
+
497
+ ## License
498
+
499
+ Apache-2.0