Skip to content

Make usage telemetry opt-in and scrub network/path data#250

Closed
jackson-asmith wants to merge 2 commits into
jamesyc:mainfrom
jackson-asmith:pr/telemetry-opt-in
Closed

Make usage telemetry opt-in and scrub network/path data#250
jackson-asmith wants to merge 2 commits into
jamesyc:mainfrom
jackson-asmith:pr/telemetry-opt-in

Conversation

@jackson-asmith

Copy link
Copy Markdown

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/*_finished events to a remote endpoint unless the user knows to set TELEMETRY=false in .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

  • Opt-in default: load_install_identity enables telemetry only for an explicit TELEMETRY=true (true/1/yes/on); render_bootstrap_text always records the choice.
  • First-run prompt: bootstrap asks once on first run. It never enables under --no-input or a non-tty stdin.
  • New command: tcapsule set-telemetry --enable/--disable/--status.
  • Payload scrubbing: before any event is sent, LAN IPv4/IPv6 addresses, SSH/SMB host targets, and local /Volumes, /mnt, /Users, /home, /private paths are dropped or redacted. The user-facing error text on the terminal is unchanged; only the telemetry copy is scrubbed.
  • Anonymous mode: TCAPSULE_TELEMETRY_ANONYMOUS=1 uses a per-run id and drops TC_CONFIGURE_ID.

Tests

  • tests/test_identity.py: default-off, explicit-flag rendering.
  • tests/test_set_telemetry.py: prompt honored, --no-input stays off, command toggles.
  • tests/test_telemetry_exposure.py: no LAN IP / host / local path survives into a payload.
  • Updated tests/test_telemetry.py / tests/test_app_api.py for the scrubbed fields.

Full suite green (python -m pytest, 1647 passed). Scoped to telemetry only per CONTRIBUTING.

🤖 Generated with Claude Code

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/timecapsulesmb/core/redaction.py Outdated
Comment on lines +34 to +49
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two critical privacy/correctness issues in the current implementation of scrub_telemetry_value:

  1. Custom Objects Bypass Redaction: If a custom object (such as an Exception or other non-primitive type) is passed to the telemetry payload, scrub_telemetry_value returns it as-is because it does not match Mapping, list/tuple/set, Path, or str. 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.
  2. Paths with Spaces Leak Data: When a Path object is passed, it is converted to a string and passed to scrub_network_and_paths, which uses _ABS_PATH_RE. However, _ABS_PATH_RE stops 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 entire Path object 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 Path string 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, and float.
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))

Comment on lines +434 to +443
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)

Comment on lines +11 to +24
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>
@jackson-asmith

Copy link
Copy Markdown
Author

Thanks — all three addressed in fd29005:

  1. Custom objects bypassing redaction (high): scrub_telemetry_value now stringifies and scrubs any non-primitive it doesn't recognize (preserving None/bool/int/float), so exceptions etc. are redacted before json.dumps(default=str) can leak them.
  2. Paths with spaces: Path objects under a sensitive prefix (/Volumes, /mnt, /Users, /home, /private) are now fully redacted rather than relying on the whitespace-terminated regex.
  3. Interrupted first-run prompt: the prompt now runs before .bootstrap is written, so a Ctrl+C during the prompt leaves nothing on disk and the next run re-prompts.

Added regression tests for all three (custom-object/Path-with-spaces scrubbing, primitive preservation, and the interrupt case).

@jamesyc

jamesyc commented Jul 13, 2026

Copy link
Copy Markdown
Owner

The README is very explicit about the telemetry being enabled by default, so it's hardly "silent egress":

The commands have logging and telemetry enabled by default. Errors and exceptions are logged so they can be easily investigated later.

Also, I don't think your Claude realized there's already a settings option to enable/disable this

image

This isn't exactly hidden. This is a checkbox in the "Privacy" section right in the middle of the Settings page in the GUI. Anyone can check/uncheck it.

@jamesyc jamesyc closed this Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants