diff --git a/docs/controller-sovereignty/README.md b/docs/controller-sovereignty/README.md new file mode 100644 index 0000000..ec70e32 --- /dev/null +++ b/docs/controller-sovereignty/README.md @@ -0,0 +1,25 @@ +# Controller Sovereignty Toolkit + +This directory defines the SourceOS controller-sovereignty lane for `sourceos-syncd`. + +The motivating observation is that modern machines are governed by autonomous controllers rather than by a simple process list. A controller is any actor that can consume material resources or mutate user/system state without a direct foreground user command. + +Examples include metadata indexing, media analysis, cloud sync, filesystem maintenance, network policy, wireless telemetry, software update, and third-party application updaters. + +The SourceOS standard is: + +> No hidden autonomous controller may consume material resources without registration, budget, logging, and revocation. + +## Contents + +- `case-file-template.md` — sanitized evidence template for controller incidents. +- `controller-registry.schema.yaml` — draft controller registry schema. +- `sovereignty-dashboard.md` — first dashboard/product model. + +## Non-goals + +This lane does not store raw private diagnostic logs, device identifiers, account identifiers, serial numbers, local IPs, Wi-Fi identifiers, packet payloads, or personal file paths. Evidence should be summarized and redacted before it becomes a repository artifact. + +## Relationship to sourceos-syncd + +`sourceos-syncd` is the state integrity daemon. Controller sovereignty extends that mission from replicated state to operating-system behavior: state changes must be observable, attributable, budgeted, repairable, and explainable before automation is allowed to act. diff --git a/docs/controller-sovereignty/case-file-template.md b/docs/controller-sovereignty/case-file-template.md new file mode 100644 index 0000000..49ef503 --- /dev/null +++ b/docs/controller-sovereignty/case-file-template.md @@ -0,0 +1,78 @@ +# Controller Case File Template + +Use this template for redacted controller-sovereignty evidence. Repository artifacts should contain summaries, not private device records. + +## Summary + +- Case ID: `` +- Date range: ` to ` +- Machine class: `` +- OS family/build: `` +- Primary finding: `` + +## Controller + +- Controller name: `` +- Owner: `` +- Representative services: + - `` +- Resource coalition: `` + +## Trigger model + +- Foreground user action observed: `` +- Background scheduler observed: `` +- Network trigger observed: `` +- Filesystem trigger observed: `` +- Cloud/sync trigger observed: `` + +## Observed behavior + +Describe what the controller did in plain language. + +Example: + +> Metadata controller compacted index payloads and dirtied several GB of file-backed memory while running non-frontmost background work. + +## Resource impact + +- CPU: `` +- Disk writes: `` +- Memory: `` +- Network: `` +- Power state: `` +- Platform action: `` + +## Evidence summary + +| Evidence | Sanitized value | +|---|---| +| Event type | `` | +| Command | `` | +| Time window | `` | +| Stack family | `` | +| User activity | `` | +| Power source | `` | + +## User-control gap + +- Was the behavior visible in the UI? +- Could the user defer it? +- Could the user budget it? +- Could the user audit what file class, interface, or service was touched? +- Could the user revise the capability safely? + +## Classification + +- Evidence confidence: `` +- Resource impact: `` +- User-control gap: `` +- Recommended design response: `` + +## SourceOS design requirement + +Translate the case into a requirement. + +Example: + +> A metadata indexer that exceeds a configured write budget must emit a ControllerBudgetExceeded event with file-class attribution and user-visible policy state. diff --git a/docs/controller-sovereignty/controller-registry.schema.yaml b/docs/controller-sovereignty/controller-registry.schema.yaml new file mode 100644 index 0000000..58a882b --- /dev/null +++ b/docs/controller-sovereignty/controller-registry.schema.yaml @@ -0,0 +1,106 @@ +version: 0.1 +controller: + id: com.sourceos.example + name: Example Controller + owner: + organization: SourceOS + trust_domain: first_party + + binaries: + - path: + signing_id: + team_id: + + launchd_services: + system: [] + user: [] + + trigger_model: + startup: false + login: false + scheduled_activity: false + push_event: false + network_event: false + filesystem_event: false + foreground_user_action: false + schedule_policy: maintenance_window_only + + capabilities: + network: + physical_wifi: false + peer_to_peer_wifi: false + low_power_network_presence: false + tunnels: false + dns: false + proxy: false + outbound_sockets: false + inbound_sockets: false + bssid_queries: false + location_inference: false + storage: + read_user_files: false + write_user_files: false + mutate_database: false + index_files: false + filesystem_traversal: false + cache_management: false + cloud: + sync: false + push_receive: false + push_send: false + private_cloud_compute: false + cross_device_pairing: false + compute: + cpu_allowed: true + gpu_allowed: false + neural_engine_allowed: false + background_threads: true + power: + allowed_while_idle: false + allowed_on_battery: false + allowed_during_sleep: false + may_wake_device: false + + budgets: + cpu: + max_percent: 10 + max_duration_seconds: 300 + action_on_exceed: pause_and_notify + memory: + max_rss_mb: 512 + action_on_exceed: notify + disk_writes: + max_mb_per_hour: 100 + max_mb_per_day: 500 + action_on_exceed: pause_and_notify + network: + max_mb_per_hour: 100 + allowed_destinations: [] + action_on_exceed: pause_and_notify + + allowed_states: + foreground: true + background: false + idle: false + locked: false + sleep: false + + observability: + audit_log: + metrics: + - cpu + - memory + - disk_writes + - network_bytes + - files_touched + - sockets_opened + - wake_events + user_visible: true + dashboard_group: Example + + controls: + pause: true + resume: true + disable: true + require_user_approval_for_budget_excess: true + require_user_approval_for_new_capability: true diff --git a/docs/controller-sovereignty/sovereignty-dashboard.md b/docs/controller-sovereignty/sovereignty-dashboard.md new file mode 100644 index 0000000..cd36fb4 --- /dev/null +++ b/docs/controller-sovereignty/sovereignty-dashboard.md @@ -0,0 +1,100 @@ +# Sovereignty Dashboard Model + +The dashboard presents a machine by controller class rather than by raw process. It should answer four questions: + +1. Who is acting? +2. What authority do they have? +3. What resource budget did they consume? +4. What can the user inspect, defer, budget, or revise? + +## Controller cards + +### Network + +Shows: + +- physical interfaces +- peer wireless interfaces +- tunnel interfaces +- proxy state +- Network Extension state +- per-process socket summary +- observed traffic classes +- low-power network presence + +### Spotlight / Metadata + +Shows: + +- indexed volumes +- active metadata workers +- recent write and CPU events +- high-churn paths +- excluded paths +- budget status + +### Photos / Media + +Shows: + +- media library services +- photo/media analysis services +- cloud photo services +- recent database/write events +- cloud sync state +- analysis workloads + +### Filesystem + +Shows: + +- filesystem traversal events +- APFS service state +- metadata maintenance +- recent CPU reports + +### Updates + +Shows: + +- platform update services +- application updater services +- recent update write events +- pending update state +- maintenance-window status + +### Security / Policy Extensions + +Shows: + +- system extensions +- Network Extensions +- EndpointSecurity clients +- firewall/filter state +- trust and attestation status + +## Minimum viable terminal view + +| Controller | State | Last event | Impact | User action | +|---|---|---|---|---| +| Spotlight / Metadata | Active | disk writes | budget exceeded | inspect / budget | +| Photos / Media | Idle | database rewrite | high | quarantine profile | +| Filesystem | Idle | CPU usage | medium | schedule | +| Wireless | Active | telemetry loop | persistent | policy view | +| Application Updater | Idle | disk writes | medium | maintenance window | +| Network Policy | Active | extension authority | unknown | inspect | + +## Required SourceOS events + +- `ControllerRegistered` +- `ControllerCapabilityDeclared` +- `ControllerBudgetExceeded` +- `ControllerTouchedFileClass` +- `ControllerOpenedNetworkSurface` +- `ControllerEnteredIdleWork` +- `ControllerExitedIdleWork` +- `ControllerPolicyChanged` + +## Product rule + +If a controller can act without a foreground user command, it must have a visible registry entry, a declared resource budget, and a human-readable audit trail. diff --git a/tools/controller-inventory.sh b/tools/controller-inventory.sh new file mode 100644 index 0000000..106e2e7 --- /dev/null +++ b/tools/controller-inventory.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Read-only SourceOS controller inventory collector. +# This script is intentionally non-mutating and does not require sudo. +# It writes a local text report for operator review. Do not commit raw output. + +set -u + +OUTDIR="${1:-$HOME/Desktop}" +STAMP="$(date +%Y%m%d-%H%M%S)" +OUT="$OUTDIR/controller-inventory-$STAMP.txt" +mkdir -p "$OUTDIR" + +section() { + printf '\n\n### %s\n' "$1" +} + +{ + section "time / system" + date + sw_vers 2>/dev/null || true + uname -a 2>/dev/null || true + uptime 2>/dev/null || true + + section "active controller processes" + ps axww -o pid,ppid,user,%cpu,%mem,rss,etime,command \ + | egrep 'photolibraryd|photoanalysisd|mediaanalysisd|cloudphotod|cloudd|fileproviderd|mds|mdworker|mds_stores|spotlight|corespotlight|apfsd|airportd|wifip2pd|wifianalyticsd|symptomsd|networkserviceproxy|nesessionmanager|Firefox|updater|softwareupdated|nsurlsessiond|rapportd|sharingd|mDNSResponder|apsd|LuLu|BlockBlock' \ + | egrep -v 'egrep' || true + + section "top cpu processes" + ps axww -o pid,ppid,user,%cpu,%mem,rss,etime,command | sort -nrk4 | head -40 + + section "recent diagnostic report names" + ls -lt /Library/Logs/DiagnosticReports "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null \ + | egrep 'photolibraryd|photoanalysisd|mediaanalysisd|cloud|mds|spotlight|corespotlight|apfsd|fileproviderd|Firefox|firefox|updater|airportd|networkserviceproxy|shutdown_stall|Jetsam|cpu_resource|diag|ips' \ + | head -160 || true + + section "spotlight indexed volumes" + mdutil -as 2>&1 || true + + section "system launchd controller services" + launchctl print system 2>/dev/null \ + | egrep -i 'metadata|mds|spotlight|corespotlight|photo|cloudphoto|photolibrary|mediaanalysis|privatecloud|cloudd|cloudkit|fileprovider|searchparty|airport|wifi|corewifi|wifip2p|wifianalytics|networkserviceproxy|nesession|mDNSResponder|rapport|sharingd|apsd' \ + | head -320 || true + + section "user launchd controller services" + launchctl print "gui/$(id -u)" 2>/dev/null \ + | egrep -i 'metadata|mds|spotlight|corespotlight|photo|cloudphoto|photolibrary|mediaanalysis|privatecloud|cloudd|cloudkit|fileprovider|searchparty|BluetoothCloud|BTServer.cloud|airport|wifi|corewifi|wifip2p|wifianalytics|networkserviceproxy|nesession|mDNSResponder|rapport|sharingd|apsd' \ + | head -420 || true + + section "interfaces" + ifconfig -a 2>&1 | egrep '^[a-z0-9]+:|status:|ether |inet |inet6 |media:|nd6 options|flags=' || true + + section "active network paths" + scutil --nwi 2>&1 || true + + section "routes ipv4" + netstat -rn -f inet 2>&1 || true + + section "routes ipv6" + netstat -rn -f inet6 2>&1 | head -220 || true + + section "dns" + scutil --dns 2>&1 || true + + section "network services" + networksetup -listallhardwareports 2>&1 || true + networksetup -listallnetworkservices 2>&1 || true + + section "system extensions" + systemextensionsctl list 2>&1 || true + + section "network extension connections" + scutil --nc list 2>&1 || true + +} | tee "$OUT" + +echo "WROTE $OUT" diff --git a/tools/resource-event-summary.py b/tools/resource-event-summary.py new file mode 100644 index 0000000..8b477da --- /dev/null +++ b/tools/resource-event-summary.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Summarize local controller resource reports into redacted rows. + +Usage: + python3 tools/resource-event-summary.py --format markdown + +The tool is read-only. It requires explicit input paths and emits summary fields only. +""" + +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import json +import re +import sys +from pathlib import Path +from typing import Any + +SUFFIXES = ('.diag', '.cpu_resource.diag', '.shutdownStall', '.ips') + +PATTERNS = { + 'date_time': r'^Date/Time:\s*(.+)$', + 'end_time': r'^End time:\s*(.+)$', + 'command': r'^Command:\s*(.+)$', + 'identifier': r'^Identifier:\s*(.+)$', + 'team_id': r'^Team ID:\s*(.+)$', + 'is_first_party': r'^Is First Party:\s*(.+)$', + 'resource_coalition': r'^Resource Coalition:\s*(.+)$', + 'event': r'^Event:\s*(.+)$', + 'action_taken': r'^Action taken:\s*(.+)$', + 'writes': r'^Writes:\s*(.+)$', + 'cpu': r'^CPU:\s*(.+)$', + 'duration': r'^Duration:\s*(.+)$', +} + +CONTROLLERS = [ + (r'mds|mdworker|mds_stores|spotlight|corespotlight', 'Spotlight / Metadata'), + (r'photolibraryd|photoanalysisd|mediaanalysisd|cloudphotod|Photos', 'Photos / Media'), + (r'apfsd', 'Filesystem'), + (r'airportd|wifip2p|wifianalytics|corewifi|WiFi', 'Wireless'), + (r'networkserviceproxy|nesessionmanager|NetworkExtension|vpn', 'Network Policy'), + (r'org\.mozilla\.updater|firefox', 'Application Updater'), + (r'cloudd|fileproviderd|cloudkit|iCloud', 'Cloud / File Provider'), + (r'ANECompilerService', 'ML / Acceleration'), +] + +STACKS = [ + (r'SpotlightIndex|CICompact|index_compact|PayloadPulses|mds', 'index compaction / payload writes'), + (r'PLCloudPhotoLibraryManager|PLResetSyncStatus|PhotoLibraryServices|NSSQL|sqlite|CoreData', 'media database rewrite'), + (r'apfsd|fts_read|fts_build|getattrlistbulk|fsctl|fstat|fstatat', 'filesystem traversal'), + (r'airportdProcessTrafficEngineeringEvents|LQM|WME|CoreWiFi|IO80211', 'wireless telemetry'), + (r'fwrite|write_nocancel', 'file writes'), +] + + +def size_mb(text: str) -> float | str: + match = re.search(r'([0-9]+(?:\.[0-9]+)?)\s*(KB|MB|GB|TB)', text or '', re.I) + if not match: + return '' + value = float(match.group(1)) + unit = match.group(2).upper() + scale = {'KB': 1 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024}[unit] + return round(value * scale, 3) + + +def cpu_avg(text: str) -> float | str: + match = re.search(r'\(([0-9]+(?:\.[0-9]+)?)%\s*cpu average\)', text or '', re.I) + return float(match.group(1)) if match else '' + + +def classify(text: str, rules: list[tuple[str, str]], default: str) -> str: + for pattern, label in rules: + if re.search(pattern, text, re.I): + return label + return default + + +def iter_inputs(paths: list[Path]): + for path in paths: + if path.is_file() and path.name.endswith(SUFFIXES): + yield path + elif path.is_dir(): + for child in sorted(path.iterdir()): + if child.is_file() and child.name.endswith(SUFFIXES): + yield child + + +def parse_report(path: Path) -> dict[str, Any]: + if path.name.endswith('.ips'): + try: + obj = json.loads(path.read_text(errors='replace')) + except Exception: + obj = {} + command = str(obj.get('procName', obj.get('bug_type', ''))) + haystack = f'{command} {path.name}' + return { + 'time': obj.get('timestamp', dt.datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec='seconds')), + 'file': path.name, + 'command': command, + 'controller': classify(haystack, CONTROLLERS, 'Unclassified'), + 'event': 'ips', + 'writes_mb': '', + 'cpu_avg_pct': '', + 'action': '', + 'stack_family': classify(json.dumps(obj)[:5000], STACKS, 'Unclassified'), + } + + text = path.read_text(errors='replace') + data = {} + for key, pattern in PATTERNS.items(): + match = re.search(pattern, text, re.M) + data[key] = match.group(1).strip() if match else '' + haystack = f"{data.get('command', '')} {path.name}" + return { + 'time': data.get('date_time') or dt.datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec='seconds'), + 'file': path.name, + 'command': data.get('command', ''), + 'controller': classify(haystack, CONTROLLERS, 'Unclassified'), + 'event': data.get('event', ''), + 'writes_mb': size_mb(data.get('writes', '')), + 'cpu_avg_pct': cpu_avg(data.get('cpu', '')), + 'action': data.get('action_taken', ''), + 'stack_family': classify(text, STACKS, 'Unclassified'), + } + + +def write_markdown(rows: list[dict[str, Any]], out) -> None: + out.write('# Controller Resource Event Summary\n\n') + out.write('| Time | Controller | Command | Event | Writes MB | CPU Avg % | Action | Stack family |\n') + out.write('|---|---|---|---|---:|---:|---|---|\n') + for row in rows: + out.write(f"| {row['time']} | {row['controller']} | {row['command']} | {row['event']} | {row['writes_mb']} | {row['cpu_avg_pct']} | {row['action']} | {row['stack_family']} |\n") + + +def write_csv(rows: list[dict[str, Any]], out) -> None: + fields = ['time', 'file', 'command', 'controller', 'event', 'writes_mb', 'cpu_avg_pct', 'action', 'stack_family'] + writer = csv.DictWriter(out, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('paths', nargs='+', type=Path) + parser.add_argument('--format', choices=['markdown', 'csv', 'json'], default='markdown') + args = parser.parse_args() + + rows = [parse_report(path) for path in iter_inputs(args.paths)] + if args.format == 'json': + json.dump(rows, sys.stdout, indent=2) + sys.stdout.write('\n') + elif args.format == 'csv': + write_csv(rows, sys.stdout) + else: + write_markdown(rows, sys.stdout) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())