- 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>
206 lines
7.1 KiB
Python
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
|