Make usage telemetry opt-in and scrub network/path data#250
Make usage telemetry opt-in and scrub network/path data#250jackson-asmith wants to merge 2 commits into
Conversation
Telemetry was previously opt-out. This makes it opt-in: - Default is disabled; enabled only by an explicit TELEMETRY=true. - bootstrap prompts once on first run (never enables under --no-input or a non-tty stdin); new `tcapsule set-telemetry --enable/--disable/--status`. - When enabled, payloads are scrubbed before send: LAN IPv4/IPv6 addresses, SSH/SMB host targets, and local /Volumes, /mnt, /Users, /home, /private paths are dropped or redacted. - TCAPSULE_TELEMETRY_ANONYMOUS=1 uses a per-run id and drops TC_CONFIGURE_ID. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request hardens telemetry privacy by making it opt-in by default, scrubbing sensitive network and local path data from payloads, and introducing an anonymous telemetry mode. Feedback on these changes highlights a critical privacy bypass in the scrubbing logic for custom objects and paths with spaces, a potential issue where interrupting the bootstrap prompt permanently disables telemetry, and an opportunity to simplify command-line argument validation using argparse.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def scrub_telemetry_value(value: object) -> object: | ||
| if isinstance(value, Mapping): | ||
| scrubbed: dict[str, object] = {} | ||
| for key, item in value.items(): | ||
| key_text = str(key) | ||
| if key_text.lower() in TELEMETRY_DROP_KEYS: | ||
| continue | ||
| scrubbed[key_text] = scrub_telemetry_value(item) | ||
| return scrubbed | ||
| if isinstance(value, (list, tuple, set)): | ||
| return [scrub_telemetry_value(item) for item in value] | ||
| if isinstance(value, Path): | ||
| return scrub_network_and_paths(str(value)) | ||
| if isinstance(value, str): | ||
| return scrub_network_and_paths(value) | ||
| return value |
There was a problem hiding this comment.
There are two critical privacy/correctness issues in the current implementation of scrub_telemetry_value:
- Custom Objects Bypass Redaction: If a custom object (such as an
Exceptionor other non-primitive type) is passed to the telemetry payload,scrub_telemetry_valuereturns it as-is because it does not matchMapping,list/tuple/set,Path, orstr. Later,json.dumps(payload, default=str)converts these objects to strings after the scrubbing phase, completely bypassing the redaction filters and potentially leaking sensitive network or path data. - Paths with Spaces Leak Data: When a
Pathobject is passed, it is converted to a string and passed toscrub_network_and_paths, which uses_ABS_PATH_RE. However,_ABS_PATH_REstops matching at spaces (using[^\s\"']*). If a user has a path with spaces (e.g.,/Volumes/Data/My Backup Folder/file.txt), only the portion up to the first space (/Volumes/Data/My) will be redacted, leaving the rest of the path (Backup Folder/file.txt) fully exposed. Since we know the entirePathobject is a path, we can safely redact the entire string if it starts with any of the sensitive prefixes.
We can solve both issues elegantly by:
- Redacting the entire
Pathstring if it starts with any of the sensitive prefixes. - Converting any unrecognized non-primitive types to strings and scrubbing them, while preserving safe primitives like
None,bool,int, andfloat.
def scrub_telemetry_value(value: object) -> object:
if isinstance(value, Mapping):
scrubbed: dict[str, object] = {}
for key, item in value.items():
key_text = str(key)
if key_text.lower() in TELEMETRY_DROP_KEYS:
continue
scrubbed[key_text] = scrub_telemetry_value(item)
return scrubbed
if isinstance(value, (list, tuple, set)):
return [scrub_telemetry_value(item) for item in value]
if isinstance(value, Path):
path_str = str(value)
if path_str.startswith(('/Volumes', '/mnt', '/Users', '/home', '/private')):
return REDACTED
return path_str
if isinstance(value, str):
return scrub_network_and_paths(value)
if value is None or isinstance(value, (bool, int, float)):
return value
return scrub_network_and_paths(str(value))| def _apply_first_run_telemetry_choice(no_input: bool) -> None: | ||
| try: | ||
| bootstrap_path = default_bootstrap_path() | ||
| first_run = load_install_identity(bootstrap_path).install_id is None | ||
| except Exception: | ||
| ensure_install_id() | ||
| return | ||
| ensure_install_id(bootstrap_path) | ||
| if first_run: | ||
| set_telemetry_enabled(_prompt_telemetry_choice(no_input), bootstrap_path) |
There was a problem hiding this comment.
In the current implementation, ensure_install_id(bootstrap_path) is called on line 441 before prompting the user for their telemetry choice on line 443.
If the user interrupts the bootstrap process (e.g., via Ctrl+C / KeyboardInterrupt) during the telemetry prompt, the .bootstrap file will have already been written with a generated INSTALL_ID and TELEMETRY=false. On any subsequent run, first_run will evaluate to False, meaning the user will never be prompted again, and telemetry will remain silently disabled without their explicit choice.
By checking first_run and prompting the user before writing the bootstrap file, we ensure that an interrupted first run can be safely retried and prompted again on the next run.
| def _apply_first_run_telemetry_choice(no_input: bool) -> None: | |
| try: | |
| bootstrap_path = default_bootstrap_path() | |
| first_run = load_install_identity(bootstrap_path).install_id is None | |
| except Exception: | |
| ensure_install_id() | |
| return | |
| ensure_install_id(bootstrap_path) | |
| if first_run: | |
| set_telemetry_enabled(_prompt_telemetry_choice(no_input), bootstrap_path) | |
| def _apply_first_run_telemetry_choice(no_input: bool) -> None: | |
| try: | |
| bootstrap_path = default_bootstrap_path() | |
| identity = load_install_identity(bootstrap_path) | |
| first_run = identity.install_id is None | |
| except Exception: | |
| ensure_install_id() | |
| return | |
| if first_run: | |
| choice = _prompt_telemetry_choice(no_input) | |
| set_telemetry_enabled(choice, bootstrap_path) | |
| else: | |
| ensure_install_id(bootstrap_path) |
| group = parser.add_mutually_exclusive_group() | ||
| group.add_argument("--enable", action="store_true", help="Enable telemetry") | ||
| group.add_argument("--disable", action="store_true", help="Disable telemetry") | ||
| group.add_argument("--status", action="store_true", help="Only report the current setting") | ||
| args = parser.parse_args(argv) | ||
|
|
||
| path = default_bootstrap_path() | ||
| if args.status: | ||
| identity = load_install_identity(path) | ||
| print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}") | ||
| return 0 | ||
|
|
||
| if not args.enable and not args.disable: | ||
| parser.error("select --enable, --disable, or --status") |
There was a problem hiding this comment.
Instead of manually checking if none of the arguments were provided on lines 23-24, you can make the mutually exclusive group required by passing required=True to add_mutually_exclusive_group(). This allows argparse to automatically handle the validation and print a standard, user-friendly error message, simplifying the code.
| group = parser.add_mutually_exclusive_group() | |
| group.add_argument("--enable", action="store_true", help="Enable telemetry") | |
| group.add_argument("--disable", action="store_true", help="Disable telemetry") | |
| group.add_argument("--status", action="store_true", help="Only report the current setting") | |
| args = parser.parse_args(argv) | |
| path = default_bootstrap_path() | |
| if args.status: | |
| identity = load_install_identity(path) | |
| print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}") | |
| return 0 | |
| if not args.enable and not args.disable: | |
| parser.error("select --enable, --disable, or --status") | |
| group = parser.add_mutually_exclusive_group(required=True) | |
| group.add_argument('--enable', action='store_true', help='Enable telemetry') | |
| group.add_argument('--disable', action='store_true', help='Disable telemetry') | |
| group.add_argument('--status', action='store_true', help='Only report the current setting') | |
| args = parser.parse_args(argv) | |
| path = default_bootstrap_path() | |
| if args.status: | |
| identity = load_install_identity(path) | |
| print(f"telemetry: {'enabled' if identity.telemetry_enabled else 'disabled'}") | |
| return 0 |
Addresses review feedback on jamesyc#250: - Scrub custom/non-primitive values (e.g. exceptions) by stringifying and redacting them before json.dumps(default=str) can leak network/path data. - Fully redact Path objects under sensitive prefixes so paths containing spaces cannot leak past the whitespace-terminated path regex. - Prompt for the telemetry choice before writing .bootstrap so an interrupted first run is re-prompted instead of left silently disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — all three addressed in fd29005:
Added regression tests for all three (custom-object/Path-with-spaces scrubbing, primitive preservation, and the interrupt case). |

What
Makes anonymous usage telemetry opt-in instead of opt-out, and scrubs network/path data from any telemetry that is sent.
Why
A default install currently emits
*_started/*_finishedevents to a remote endpoint unless the user knows to setTELEMETRY=falsein.bootstrap. For a LAN backup tool that many users treat as semi-trusted, silent egress during setup is a poor default. This flips it so a default install sends nothing.Changes
load_install_identityenables telemetry only for an explicitTELEMETRY=true(true/1/yes/on);render_bootstrap_textalways records the choice.bootstrapasks once on first run. It never enables under--no-inputor a non-tty stdin.tcapsule set-telemetry --enable/--disable/--status./Volumes,/mnt,/Users,/home,/privatepaths are dropped or redacted. The user-facing error text on the terminal is unchanged; only the telemetry copy is scrubbed.TCAPSULE_TELEMETRY_ANONYMOUS=1uses a per-run id and dropsTC_CONFIGURE_ID.Tests
tests/test_identity.py: default-off, explicit-flag rendering.tests/test_set_telemetry.py: prompt honored,--no-inputstays off, command toggles.tests/test_telemetry_exposure.py: no LAN IP / host / local path survives into a payload.tests/test_telemetry.py/tests/test_app_api.pyfor the scrubbed fields.Full suite green (
python -m pytest, 1647 passed). Scoped to telemetry only per CONTRIBUTING.🤖 Generated with Claude Code