Skip to content

Commit 002dbb0

Browse files
committed
Define correction exit statuses
Distinguish emitted suggestions from failed, incomplete, ambiguous, or refused correction attempts. Residue repairs now use the same suggestion status as string repairs, and the security model records the command-specific status for disclosure refusal. Security: disclosure refusals remain fail-closed and are now machine-distinguishable from emitted suggestions. Validated on the identical pre-rewrite tree with normal and optimized full test suites, Ruff, formatting, and mypy. Fixes #39.
1 parent a77ab52 commit 002dbb0

5 files changed

Lines changed: 30 additions & 20 deletions

File tree

‎docs/security/model.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,12 @@ before printing any candidate text, metadata, fingerprint, or residue addends.
199199
It then prints a conspicuous warning covering both deliberate completion of
200200
newly transcribed data and recovery with many missing characters. Literal
201201
uppercase `YES` is required before disclosure; other case variants, blank input,
202-
or EOF terminate the command with status 1. Redirected damaged data may still
203-
reach this gate, but disclosure requires an interactive terminal channel. If no
204-
such channel is available, the sole message is `codex32: interactive confirmation
205-
required` (or `ms32:`). Output formatting and `--plain` cannot bypass the gate.
202+
or EOF terminate standalone `correct` with status 3 and correction embedded in
203+
another workflow with status 1. Redirected damaged data may still reach this
204+
gate, but disclosure requires an interactive terminal channel. If no such
205+
channel is available, the sole message is `codex32: interactive confirmation
206+
required` (or `ms32:`), with the same command-specific status. Output formatting
207+
and `--plain` cannot bypass the gate.
206208
Existing whole-card `[y/N]` acceptance remains required after disclosure when a
207209
workflow will consume the corrected artifact. `correct` only displays the
208210
suggestion, so it has no second acceptance prompt. The gate does not verify the

‎docs/user/guide.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ disclosure, workflows that consume the repaired artifact ask the usual `[y/N]`
3232
whole-card confirmation. `correct` only reports a suggestion, so it does not ask
3333
that second question. A checksum cannot make weak input secure.
3434

35+
The `correct` exit status distinguishes outcomes for scripts: `0` means the
36+
input is already valid, `1` means a suggestion was emitted, `2` means the
37+
command or input syntax was invalid, and `3` means no usable suggestion was
38+
emitted. Status `3` includes incomplete or ambiguous searches, declined
39+
disclosure, and an unavailable dependency needed to present a suggestion.
40+
3541
Choose the setup that fits you:
3642

3743
- **Recommended: dedicated online spending wallet — easiest.** A normally

‎src/codex32/cli.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ def _correct(
537537
f"Add {correction.addend} at position "
538538
f"{correction.reverse_index + 1}, counting backward from the end."
539539
)
540-
return 0
540+
return 1 if result else 0
541541
if erasures:
542542
raise _UsageError("--erasure can be used only with --residue.")
543543
normalized = "".join(value.split())
@@ -710,22 +710,23 @@ def _main(context: _CliContext, argv: Sequence[str] | None = None) -> int:
710710
except SystemExit as error:
711711
return error.code if isinstance(error.code, int) else 1
712712
scope = f"{context.prog} {arguments.command}"
713+
correction_failed = 3 if arguments.command == "correct" else 1
713714
try:
714715
return _dispatch(arguments, context)
715716
except CorrectionDeclined:
716-
return 1
717+
return correction_failed
717718
except InteractiveConfirmationRequired:
718719
_print(f"{context.prog}: interactive confirmation required", err=True)
719-
return 1
720+
return correction_failed
720721
except _UsageError as error:
721722
_print(f"{scope}: {error}", err=True)
722723
return 2
723724
except (_CommandError, CodexError) as error:
724725
_print(f"{scope}: {error}", err=True)
725-
return 1
726+
return correction_failed
726727
except BitcoinCoreError as error:
727728
_print(f"{scope}: {error}", err=True)
728-
return 1
729+
return correction_failed
729730
except EOFError:
730731
_print(f"{scope}: Input ended before recovery completed.", err=True)
731732
return 2

‎tests/test_cli.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,7 +1713,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None:
17131713

17141714
result = _invoke(["correct"], damaged)
17151715

1716-
assert result.exit_code == 1
1716+
assert result.exit_code == 3
17171717
assert "No valid correction found" in result.stderr
17181718

17191719

@@ -1724,7 +1724,7 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() ->
17241724

17251725
assert len("".join(damaged.split())) == 48
17261726
assert damaged.count("?") == 16
1727-
assert result.exit_code == 1
1727+
assert result.exit_code == 3
17281728
assert "No valid correction found" in result.stderr
17291729

17301730

@@ -1766,7 +1766,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None:
17661766
with patch("codex32.cli._correction_candidates", return_value=((), False, 0.0, False)):
17671767
result = _invoke(["correct"], damaged)
17681768

1769-
assert result.exit_code != 0
1769+
assert result.exit_code == 3
17701770
assert result.stdout == ""
17711771
assert "did not complete" in result.stderr and original not in result.stderr
17721772

@@ -1788,7 +1788,7 @@ def test_correction_options_control_lengths_deadline_and_search_envelope(
17881788
with patch("codex32.indel._search_many", return_value=((), True)) as search:
17891789
result = _invoke(["correct", *options], damaged)
17901790

1791-
assert result.exit_code == 1 and result.stdout == ""
1791+
assert result.exit_code == 3 and result.stdout == ""
17921792
assert search.call_count == 1
17931793
contexts, observed = search.call_args.args
17941794
assert observed == damaged
@@ -1815,7 +1815,7 @@ def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions
18151815
residue = _invoke(["correct", "--residue"], "2ppjkw73qdjvc")
18161816

18171817
assert fixed.exit_code == 1 and original in fixed.stderr
1818-
assert residue.exit_code == 0
1818+
assert residue.exit_code == 1
18191819
assert "Add x at position 38, counting backward from the end." in residue.stdout
18201820

18211821

@@ -1848,7 +1848,7 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None:
18481848
bip39 = _invoke(["correct"], BIP39_12W_ZERO)
18491849
assert removed.exit_code == 2
18501850
assert "Remove or correct these arguments: --prefix" in removed.stderr
1851-
assert damaged_prefix.exit_code == 1
1851+
assert damaged_prefix.exit_code == 3
18521852
assert bip39.exit_code == 0 and "already valid" in bip39.stdout
18531853

18541854

@@ -1915,7 +1915,7 @@ def stop_after_first(value, *_args, **_kwargs):
19151915
def test_correction_hides_internal_candidate_reparse_failures() -> None:
19161916
result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg")
19171917

1918-
assert result.exit_code != 0
1918+
assert result.exit_code == 3
19191919
assert result.stdout == ""
19201920
assert result.stderr.strip() in {
19211921
"codex32 correct: No valid correction found. Check the original backup.",

‎tests/test_correction_disclosure.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain):
104104
):
105105
status = entrypoint(["correct", *(["--plain"] if plain else [])])
106106
prog = "codex32" if entrypoint is cli.main else "ms32"
107-
assert status == 1 and stdout.getvalue() == ""
107+
assert status == 3 and stdout.getvalue() == ""
108108
assert stderr.getvalue() == f"{prog}: interactive confirmation required\n"
109109

110110

@@ -140,7 +140,8 @@ def respond(prompt, prefill=""):
140140
contextlib.redirect_stderr(stderr),
141141
):
142142
status = (cli.ms_main if command == "create" else cli.main)(args)
143-
assert status == 1 and stdout.getvalue() == ""
143+
expected_status = 3 if command == "correct" else 1
144+
assert status == expected_status and stdout.getvalue() == ""
144145
assert len(prompts) == 2 and core.imported is None
145146
warning = stderr.getvalue()
146147
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):
189190
contextlib.redirect_stdout(stdout),
190191
contextlib.redirect_stderr(stderr),
191192
):
192-
assert cli.main(["correct", "--plain"]) == 1
193+
assert cli.main(["correct", "--plain"]) == 3
193194
assert stdout.getvalue() == ""
194195
assert stderr.getvalue().strip() == "codex32: interactive confirmation required"
195196

@@ -292,7 +293,7 @@ def test_residue_completion_is_gated_but_ordinary_repair_is_not(degree):
292293
contextlib.redirect_stdout(stdout),
293294
contextlib.redirect_stderr(stderr),
294295
):
295-
assert cli.main(args) == 1
296+
assert cli.main(args) == 3
296297
assert stdout.getvalue() == ""
297298
assert stderr.getvalue() == "codex32: interactive confirmation required\n"
298299

0 commit comments

Comments
 (0)