replica-omnisciente/scripts/data_sharing/consent.py
Raphael Cautus (Maestro) 2f26f2d836 feat(scripts): onboarding, GWS, lifestream, muscriptor, music, data-sharing
- onboard-client.py: client replica scaffolding CLI
- gws/: Google Workspace sync (Gmail, Calendar, Drive)
- lifestream/: life event stream collector
- muscriptor-mcp/: audio → MIDI MCP server
- music-mcp/: music library MCP server
- data_sharing/: consent-gated data sharing (Python + TS)
- sync-mirrors.py: GitHub → Forgejo mirror engine
- brain-to-gbrain.py, vault-sync.py, test-all.sh
- shared/: TS data-sharing library + index
- dirac: provider registry update
- .gitignore: exclude Rust build artifacts

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
2026-07-31 14:58:02 +01:00

206 lines
7.1 KiB
Python

"""
Consent model — granular opt-in for what data a client authorizes sending
to Portugal Futurista.
The consent layer is the gatekeeper: nothing leaves the local replica unless
the corresponding data category is explicitly enabled in config.toml under
[data_sharing].
Consent is read from the workspace config.toml:
[data_sharing]
enabled = false # master switch — if false, nothing is sent
transport = "http" # http | ssh | local | s3 | proxmox
endpoint = "https://mcp.portugalfuturista.org/api/ingest"
[data_sharing.categories]
tool_calls = false # tool invocations + arguments + results
thinking = false # chain-of-thought / reasoning traces
chat_messages = false # user/assistant message bodies
session_meta = false # session ids, timestamps, workspace paths
agent_metadata = false # heteronym, model, token counts
error_traces = false # exceptions, stack traces, stderr
file_changes = false # git diffs, patched files
environment = false # OS, hostname, shell (telemetry only)
[data_sharing.retention]
days = 90 # how long PF retains the data
redact_secrets = true # strip API keys / tokens before sending
Consent defaults to ALL FALSE. Explicit opt-in required for every category.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import os
try:
import tomllib # Python 3.11+
except ImportError:
try:
import tomli as tomllib # type: ignore
except ImportError:
tomllib = None # type: ignore
# Canonical data categories — order matters for display
CATEGORIES = (
"tool_calls", # tool invocations + arguments + results
"thinking", # chain-of-thought / reasoning traces
"chat_messages", # user/assistant message bodies
"session_meta", # session ids, timestamps, workspace paths
"agent_metadata", # heteronym, model, token counts
"error_traces", # exceptions, stack traces, stderr
"file_changes", # git diffs, patched files
"environment", # OS, hostname, shell (telemetry only)
)
DEFAULT_RETENTION_DAYS = 90
@dataclass
class ConsentRecord:
"""The resolved consent state for a single client replica."""
enabled: bool = False
transport: str = "http"
endpoint: str = ""
# Per-category opt-in — ALL default to False (opt-in required)
categories: dict[str, bool] = field(default_factory=lambda: {c: False for c in CATEGORIES})
# Retention + redaction
retention_days: int = DEFAULT_RETENTION_DAYS
redact_secrets: bool = True
# Raw config for debugging
_raw: dict[str, Any] | None = field(default=None, repr=False)
def allows(self, category: str) -> bool:
"""Check if a data category is consented for sharing."""
if not self.enabled:
return False
return self.categories.get(category, False)
def allows_any(self, *categories: str) -> bool:
"""Check if any of the given categories are consented."""
return any(self.allows(c) for c in categories)
def allows_all(self, *categories: str) -> bool:
"""Check if all given categories are consented."""
return all(self.allows(c) for c in categories)
def granted_categories(self) -> list[str]:
"""Return list of explicitly consented categories."""
return [c for c in CATEGORIES if self.allows(c)]
def to_display(self) -> str:
"""Human-readable summary for CLI display."""
lines = [f" Master switch: {'ON' if self.enabled else 'OFF'}"]
lines.append(f" Transport: {self.transport}")
lines.append(f" Endpoint: {self.endpoint or '(not set)'}")
lines.append(f" Categories:")
for cat in CATEGORIES:
state = "ON" if self.allows(cat) else "off"
lines.append(f" {cat:<18} {state}")
lines.append(f" Retention: {self.retention_days} days")
lines.append(f" Redact secrets: {self.redact_secrets}")
return "\n".join(lines)
def _parse_toml(path: Path) -> dict[str, Any]:
"""Parse a TOML file, returning {} on missing/unparseable."""
if not path.exists():
return {}
if tomllib is None:
raise RuntimeError(
"No TOML parser available. Install `tomli` (pip install tomli) "
"or use Python 3.11+."
)
with open(path, "rb") as f:
return tomllib.load(f)
def load_consent(config_path: str | Path | None = None) -> ConsentRecord:
"""
Load consent state from the workspace config.toml.
Search order (first found wins):
1. Explicit path argument
2. <cwd>/.aurelio/config.toml
3. <cwd>/config.toml
4. ~/.aurelio/config.toml (global defaults)
"""
candidates: list[Path] = []
if config_path:
candidates.append(Path(config_path))
else:
cwd = Path.cwd()
candidates.append(cwd / ".aurelio" / "config.toml")
candidates.append(cwd / "config.toml")
home = Path.home()
candidates.append(home / ".aurelio" / "config.toml")
config: dict[str, Any] = {}
for p in candidates:
if p.exists():
config = _parse_toml(p)
break
return parse_consent(config)
def parse_consent(config: dict[str, Any]) -> ConsentRecord:
"""Parse a raw config dict (already loaded TOML) into a ConsentRecord."""
ds = config.get("data_sharing", {})
cats_raw = ds.get("categories", {})
categories = {c: bool(cats_raw.get(c, False)) for c in CATEGORIES}
retention = ds.get("retention", {})
return ConsentRecord(
enabled=bool(ds.get("enabled", False)),
transport=ds.get("transport", "http"),
endpoint=ds.get("endpoint", ""),
categories=categories,
retention_days=int(retention.get("days", DEFAULT_RETENTION_DAYS)),
redact_secrets=bool(retention.get("redact_secrets", True)),
_raw=ds,
)
# ─── Secret redaction ──────────────────────────────────────────────
# Patterns that look like secrets — used when redact_secrets=True
_SECRET_PATTERNS = [
# API keys (common formats)
(r"sk-[a-zA-Z0-9]{20,}", "sk-[REDACTED]"),
(r"gh[pousr]_[A-Za-z0-9]{36}", "ghp_[REDACTED]"),
(r"github_pat_[A-Za-z0-9_]{82}", "github_pat_[REDACTED]"),
(r"AIza[a-zA-Z0-9_\\-]{35}", "AIza[REDACTED]"),
# Generic tokens in env-like assignments
(r"(?i)(token|key|secret|password|passwd|api_key|apikey)\s*[=:]\s*['\"]?[^\s'\"\\]{8,}", r"\1=[REDACTED]"),
# Bearer tokens
(r"(?i)bearer\s+[a-zA-Z0-9_\-\.]{20,}", "bearer [REDACTED]"),
]
def _compile_patterns():
import re
return [(re.compile(p, re.IGNORECASE), r) for p, r in _SECRET_PATTERNS]
_compiled = None
def redact(text: str) -> str:
"""Redact known secret patterns from a text string."""
global _compiled
if _compiled is None:
_compiled = _compile_patterns()
for pattern, replacement in _compiled:
text = pattern.sub(replacement, text)
return text