From 794f898aa40679a9285f49f4f03fdfb432699cd3 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 09:39:16 -0500 Subject: [PATCH 1/2] correct: Define correction exit statuses Give standalone correction a stable status contract: 0 for already-valid input, 1 when a suggestion is emitted, 2 for command or input syntax errors, and 3 when no usable suggestion is emitted. Keep incomplete best-effort suggestions at status 1 and document status 3 only for incomplete searches without a usable suggestion. Fixes #39. --- docs/security/model.md | 10 ++++++---- docs/user/guide.md | 7 +++++++ src/codex32/cli.py | 18 +++++++++--------- tests/test_cli.py | 25 +++++++++++++++++-------- tests/test_correction_disclosure.py | 9 +++++---- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/docs/security/model.md b/docs/security/model.md index 70cbbd8..945fc83 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -199,10 +199,12 @@ before printing any candidate text, metadata, fingerprint, or residue addends. It then prints a conspicuous warning covering both deliberate completion of newly transcribed data and recovery with many missing characters. Literal uppercase `YES` is required before disclosure; other case variants, blank input, -or EOF terminate the command with status 1. Redirected damaged data may still -reach this gate, but disclosure requires an interactive terminal channel. If no -such channel is available, the sole message is `codex32: interactive confirmation -required` (or `ms32:`). Output formatting and `--plain` cannot bypass the gate. +or EOF terminate standalone `correct` with status 3 and correction embedded in +another workflow with status 1. Redirected damaged data may still reach this +gate, but disclosure requires an interactive terminal channel. If no such +channel is available, the sole message is `codex32: interactive confirmation +required` (or `ms32:`), with the same command-specific status. Output formatting +and `--plain` cannot bypass the gate. Existing whole-card `[y/N]` acceptance remains required after disclosure when a workflow will consume the corrected artifact. `correct` only displays the suggestion, so it has no second acceptance prompt. The gate does not verify the diff --git a/docs/user/guide.md b/docs/user/guide.md index eedd5a7..9c31137 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -32,6 +32,13 @@ disclosure, workflows that consume the repaired artifact ask the usual `[y/N]` whole-card confirmation. `correct` only reports a suggestion, so it does not ask that second question. A checksum cannot make weak input secure. +The `correct` exit status distinguishes outcomes for scripts: `0` means the +input is already valid, `1` means a suggestion was emitted, `2` means the +command or input syntax was invalid, and `3` means no usable suggestion was +emitted. Status `3` includes incomplete searches with no usable suggestion, +ambiguous searches, declined disclosure, and an unavailable dependency needed +to present a suggestion. + Choose the setup that fits you: - **Recommended: dedicated online spending wallet — easiest.** A normally diff --git a/src/codex32/cli.py b/src/codex32/cli.py index ba61b44..53af9c6 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -539,14 +539,16 @@ def _correct( f"Add {correction.addend} at position " f"{correction.reverse_index + 1}, counting backward from the end." ) - return 0 + return 1 if result else 0 if erasures: raise _UsageError("--erasure can be used only with --residue.") normalized = "".join(value.split()) separator = normalized.lower().rfind("1") if separator <= 0: raise _UsageError("Enter a complete application prefix followed by the separator 1.") - hrp = normalized[:separator].lower() + if len(raw_hrp := normalized[:separator]) > 83 or not all("!" <= c <= "~" for c in raw_hrp): + raise _UsageError("The application prefix must be at most 83 printable ASCII characters.") + hrp = raw_hrp.lower() if context.master_seed and hrp != Profile.MS.value: raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.") try: @@ -746,22 +748,20 @@ def _main(context: _CliContext, argv: Sequence[str] | None = None) -> int: except SystemExit as error: return error.code if isinstance(error.code, int) else 1 scope = f"{context.prog} {arguments.command}" + correction_failed = 3 if arguments.command == "correct" else 1 try: return _dispatch(arguments, context) except CorrectionDeclined: - return 1 + return correction_failed except InteractiveConfirmationRequired: _print(f"{context.prog}: interactive confirmation required", err=True) - return 1 + return correction_failed except _UsageError as error: _print(f"{scope}: {error}", err=True) return 2 - except (_CommandError, CodexError) as error: - _print(f"{scope}: {error}", err=True) - return 1 - except BitcoinCoreError as error: + except (_CommandError, CodexError, BitcoinCoreError) as error: _print(f"{scope}: {error}", err=True) - return 1 + return correction_failed except EOFError: _print(f"{scope}: Input ended before recovery completed.", err=True) return 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 814b89d..3e58455 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1713,7 +1713,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None: result = _invoke(["correct"], damaged) - assert result.exit_code == 1 + assert result.exit_code == 3 assert "No valid correction found" in result.stderr @@ -1724,10 +1724,19 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> assert len("".join(damaged.split())) == 48 assert damaged.count("?") == 16 - assert result.exit_code == 1 + assert result.exit_code == 3 assert "No valid correction found" in result.stderr +def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: + damaged = "é" + VECTOR_1["secret_s"][1:] + + result = _invoke(["correct"], damaged) + + assert result.exit_code == 2 + assert "application prefix" in result.stderr + + @pytest.mark.parametrize( ("byte_length", "options"), ((16, []), (64, []), (20, ["--bytes", "20"]), (24, ["--bytes", "?"])), @@ -1766,7 +1775,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None: with patch("codex32.cli._correction_candidates", return_value=((), False, 0.0, False)): result = _invoke(["correct"], damaged) - assert result.exit_code != 0 + assert result.exit_code == 3 assert result.stdout == "" assert "did not complete" in result.stderr and original not in result.stderr @@ -1788,7 +1797,7 @@ def test_correction_options_control_lengths_deadline_and_search_envelope( with patch("codex32.indel._search_many", return_value=((), True)) as search: result = _invoke(["correct", *options], damaged) - assert result.exit_code == 1 and result.stdout == "" + assert result.exit_code == 3 and result.stdout == "" assert search.call_count == 1 contexts, observed = search.call_args.args assert observed == damaged @@ -1815,7 +1824,7 @@ def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions residue = _invoke(["correct", "--residue"], "2ppjkw73qdjvc") assert fixed.exit_code == 1 and original in fixed.stderr - assert residue.exit_code == 0 + assert residue.exit_code == 1 assert "Add x at position 38, counting backward from the end." in residue.stdout @@ -1848,7 +1857,7 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None: bip39 = _invoke(["correct"], BIP39_12W_ZERO) assert removed.exit_code == 2 assert "Remove or correct these arguments: --prefix" in removed.stderr - assert damaged_prefix.exit_code == 1 + assert damaged_prefix.exit_code == 3 assert bip39.exit_code == 0 and "already valid" in bip39.stdout @@ -1975,14 +1984,14 @@ def incomplete_first(value, *_args, **kwargs): assert len(full_searches) == 2 assert full_searches[0].count("?") == len(positions) assert "?" not in full_searches[1] - assert result.exit_code == 1 + assert result.exit_code == 3 assert "interactive confirmation required" in result.stderr def test_correction_hides_internal_candidate_reparse_failures() -> None: result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg") - assert result.exit_code != 0 + assert result.exit_code == 3 assert result.stdout == "" assert result.stderr.strip() in { "codex32 correct: No valid correction found. Check the original backup.", diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index cb30d48..95501d4 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -104,7 +104,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain): ): status = entrypoint(["correct", *(["--plain"] if plain else [])]) prog = "codex32" if entrypoint is cli.main else "ms32" - assert status == 1 and stdout.getvalue() == "" + assert status == 3 and stdout.getvalue() == "" assert stderr.getvalue() == f"{prog}: interactive confirmation required\n" @@ -140,7 +140,8 @@ def respond(prompt, prefill=""): contextlib.redirect_stderr(stderr), ): status = (cli.ms_main if command == "create" else cli.main)(args) - assert status == 1 and stdout.getvalue() == "" + expected_status = 3 if command == "correct" else 1 + assert status == expected_status and stdout.getvalue() == "" assert len(prompts) == 2 and core.imported is None warning = stderr.getvalue() assert "\x1b[1;31mWarning:\x1b[0m If you are generating new data" in warning @@ -189,7 +190,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch): contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): - assert cli.main(["correct", "--plain"]) == 1 + assert cli.main(["correct", "--plain"]) == 3 assert stdout.getvalue() == "" assert stderr.getvalue().strip() == "codex32: interactive confirmation required" @@ -292,7 +293,7 @@ def test_residue_completion_is_gated_but_ordinary_repair_is_not(degree): contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): - assert cli.main(args) == 1 + assert cli.main(args) == 3 assert stdout.getvalue() == "" assert stderr.getvalue() == "codex32: interactive confirmation required\n" From 07ee68c8786dc975e5b55057cfa96e9e5d44d758 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 30 Sep 2026 17:31:52 -0500 Subject: [PATCH 2/2] docs: Name Core in correction exit status The status-3 wording called Bitcoin Core an unavailable dependency, which reads like a Python package dependency. Name the actual optional runtime service and the ms32 path that uses it. The generic codex32 correct command remains Core-independent. Refs #39. --- docs/user/guide.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user/guide.md b/docs/user/guide.md index 9c31137..e72f227 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -36,8 +36,9 @@ The `correct` exit status distinguishes outcomes for scripts: `0` means the input is already valid, `1` means a suggestion was emitted, `2` means the command or input syntax was invalid, and `3` means no usable suggestion was emitted. Status `3` includes incomplete searches with no usable suggestion, -ambiguous searches, declined disclosure, and an unavailable dependency needed -to present a suggestion. +ambiguous searches, declined disclosure, and Bitcoin Core being unavailable +when `ms32 correct` needs it to rank or fingerprint a master-seed suggestion. +The generic `codex32 correct` command does not need Core. Choose the setup that fits you: