Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions gittensor/cli/issue_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,20 @@ def confirm_or_abort(prompt: str, yes: bool, default: bool = False) -> bool:
"""Prompt for confirmation before a destructive operation.

Returns True if the caller should proceed. Returns False (and prints a
cancellation message) if the user declines. `yes` and non-TTY input both
skip the prompt and proceed.
cancellation message) if the user declines.

``yes`` is the only way to skip the prompt. A non-interactive stdin exits
non-zero instead of self-confirming: these operations transfer ownership,
move bounty funds, and edit the validator whitelist irreversibly, so an
unattended run must fail closed. Scripts and CI opt in explicitly with
``--yes``.
"""
if yes or not _is_interactive():
if yes:
return True
if not _is_interactive():
print_error(f'Confirmation required: {prompt}')
err_console.print('[yellow]stdin is not interactive — re-run with --yes to confirm.[/yellow]')
raise SystemExit(1)
if click.confirm(f'\n{prompt}', default=default):
return True
err_console.print('[yellow]Cancelled.[/yellow]')
Expand Down
6 changes: 2 additions & 4 deletions gittensor/cli/issue_commands/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from .help import StyledCommand
from .helpers import (
NETWORK_CHOICE,
_is_interactive,
_resolve_contract_and_network,
confirm_or_abort,
console,
err_console,
format_alpha,
Expand Down Expand Up @@ -169,9 +169,7 @@ def issue_register(
)
)

skip_confirm = yes or not _is_interactive()
if not skip_confirm and not click.confirm('\nProceed with registration?', default=True):
err_console.print('[yellow]Registration cancelled.[/yellow]')
if not confirm_or_abort('Proceed with registration?', yes, default=True):
return

try:
Expand Down
4 changes: 4 additions & 0 deletions tests/cli/test_cli_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,10 @@ def test_vote_solution_import_error_exits_non_zero(self, cli_root, runner):
'5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
'5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
'1',
# CliRunner stdin is not a TTY, so the confirmation gate would
# abort before the client is built. --yes is the supported
# non-interactive opt-in and keeps this test on the import path.
'--yes',
],
catch_exceptions=False,
)
Expand Down
98 changes: 98 additions & 0 deletions tests/cli/test_confirm_or_abort.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# The MIT License (MIT)
# Copyright © 2025 Entrius

"""Tests for the destructive-operation confirmation gate.

``confirm_or_abort`` guards ownership transfer, bounty payouts, and validator
whitelist edits. A non-interactive stdin must fail closed rather than
self-confirm, so an unattended run can never perform an irreversible action
that the operator never approved.
"""

from unittest.mock import patch

import pytest

from gittensor.cli.issue_commands.helpers import confirm_or_abort


class TestConfirmOrAbortInteractive:
"""With a TTY the prompt decides."""

def test_accepts_when_user_confirms(self):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=True),
patch('gittensor.cli.issue_commands.helpers.click.confirm', return_value=True),
):
assert confirm_or_abort('Transfer ownership?', yes=False) is True

def test_returns_false_when_user_declines(self):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=True),
patch('gittensor.cli.issue_commands.helpers.click.confirm', return_value=False),
):
assert confirm_or_abort('Transfer ownership?', yes=False) is False

def test_default_is_forwarded_to_prompt(self):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=True),
patch('gittensor.cli.issue_commands.helpers.click.confirm', return_value=True) as confirm,
):
confirm_or_abort('Proceed with registration?', yes=False, default=True)
assert confirm.call_args.kwargs['default'] is True


class TestConfirmOrAbortYesFlag:
"""--yes is the only supported way to skip the prompt."""

def test_yes_skips_prompt_on_a_tty(self):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=True),
patch('gittensor.cli.issue_commands.helpers.click.confirm') as confirm,
):
assert confirm_or_abort('Pay out issue 1?', yes=True) is True
confirm.assert_not_called()

def test_yes_skips_prompt_without_a_tty(self):
"""Scripts and CI opt in explicitly and keep working."""
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=False),
patch('gittensor.cli.issue_commands.helpers.click.confirm') as confirm,
):
assert confirm_or_abort('Pay out issue 1?', yes=True) is True
confirm.assert_not_called()


class TestConfirmOrAbortNonInteractiveFailsClosed:
"""Without --yes, a non-TTY stdin aborts instead of silently proceeding."""

def test_exits_non_zero_instead_of_proceeding(self):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=False),
patch('gittensor.cli.issue_commands.helpers.click.confirm') as confirm,
pytest.raises(SystemExit) as exc,
):
confirm_or_abort('Transfer ownership to 5Hxxx?', yes=False)

assert exc.value.code == 1
# Never fell through to a prompt that cannot be answered.
confirm.assert_not_called()

def test_error_names_the_action_and_the_opt_in_flag(self, capsys):
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=False),
pytest.raises(SystemExit),
):
confirm_or_abort('Transfer ownership to 5Hxxx?', yes=False)

output = capsys.readouterr().err
assert 'Transfer ownership to 5Hxxx?' in output
assert '--yes' in output

def test_default_true_does_not_auto_confirm_without_a_tty(self):
"""A permissive prompt default must not become an implicit approval."""
with (
patch('gittensor.cli.issue_commands.helpers._is_interactive', return_value=False),
pytest.raises(SystemExit),
):
confirm_or_abort('Proceed with registration?', yes=False, default=True)