Skip to content

let-fate-decide: replace os.urandom with secrets.randbelow#125

Merged
dguido merged 7 commits intomainfrom
let-fate-decide/secrets-randbelow
Apr 1, 2026
Merged

let-fate-decide: replace os.urandom with secrets.randbelow#125
dguido merged 7 commits intomainfrom
let-fate-decide/secrets-randbelow

Conversation

@tob-joe
Copy link
Copy Markdown
Contributor

@tob-joe tob-joe commented Mar 17, 2026

Summary

  • Replace hand-rolled secure_randbelow(os.urandom) with stdlib secrets.randbelow() per @opal-escent's feedback
  • Remove custom rejection-sampling function (semantically identical to secrets.randbelow)
  • Use secrets.randbelow(2) for card reversal coin flips instead of os.urandom(1)[0] & 1
  • Update all docstrings, SKILL.md, and README.md references from os.urandom() to secrets.randbelow()
  • Add 25-test suite: deck construction, shuffle invariants, draw behavior, CLI validation, and 4 migration regression checks

Test plan

  • All 25 tests pass (python3 test_draw_cards.py)
  • No references to os.urandom or import os remain in draw_cards.py
  • Custom secure_randbelow function fully removed
  • SKILL.md and README.md updated consistently
  • Script produces valid JSON output with correct structure

cc @opal-escent for review

🤖 Generated with Claude Code

Switch card selection from hand-rolled secure_randbelow(os.urandom)
to stdlib secrets.randbelow(). Update all docstrings, SKILL.md, and
README.md references. Add 25-test suite covering deck construction,
shuffle invariants, CLI validation, and migration regression checks.
@tob-joe tob-joe requested a review from opal-escent March 17, 2026 17:01
dguido and others added 3 commits March 17, 2026 14:04
- Bump version in plugin.json and marketplace.json (1.0.0 → 1.1.0)
- Update SKILL.md to say "via secrets.randbelow()" instead of claiming
  the script itself implements rejection sampling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Incorporates unique changes from Scott's closed PR #128:
- Broadened trigger patterns (casual delegation, YOLO, shrug-like
  brevity, expanded Yu-Gi-Oh references) in README.md and SKILL.md
- Improved SKILL.md frontmatter description with casual/playful
  tone guidance and prefer-over-ask-questions-if-underspecified note
- is_reversed() uses secrets.randbits(1) (more idiomatic coin flip)

Additional fixes from vivisect analysis:
- Converted MAJOR_ARCANA, RANKS, SUITS from lists to tuples
  (prevents silent deck corruption if imported as library)
- Added type guard in draw() rejecting non-int/bool inputs
- Added test_constants_are_immutable and test_draw_rejects_non_int

Co-Authored-By: Scott Arciszewski <scott.arciszewski@trailofbits.com>
tob-scott-a
tob-scott-a previously approved these changes Mar 19, 2026
Copy link
Copy Markdown
Member

@dguido dguido left a comment

Choose a reason for hiding this comment

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

Review: let-fate-decide secrets.randbelow migration

Verdict: Approve

Summary

Clean migration from a custom secure_randbelow() (manual rejection sampling over os.urandom()) to stdlib secrets.randbelow() and secrets.randbits(). This is the right call -- secrets.randbelow() does the same rejection sampling internally, so the custom implementation was redundant.

What was checked

  • No merge conflicts with main
  • Versions match: plugin.json and marketplace.json both at 1.1.0
  • YAML frontmatter: valid, name is kebab-case, description is third-person with trigger phrases
  • No hardcoded paths
  • All 27 tests pass (deck construction, shuffle properties, CLI integration, regression checks for the migration)
  • Codex skills validation passes
  • Plugin metadata validation passes
  • ruff clean on both Python files

Code quality notes

  • Fisher-Yates shuffle is correctly implemented with secrets.randbelow(i + 1)
  • secrets.randbits(1) for the coin flip is cleaner than os.urandom(1)[0] & 1
  • Constants converted from lists to tuples (immutability) with matching test
  • Type guard on draw(n) rejecting bools and non-ints is a nice touch
  • Test suite is thorough: covers deck structure, shuffle invariants, randomness smoke tests, CLI args, and regression tests for the os-to-secrets migration

Minor observations (non-blocking)

  • draw(0) silently returns [] at the library level but the CLI rejects 0 as out-of-range. This is fine since the CLI has its own validation layer, but worth noting the asymmetry.
  • draw(-5) also silently returns [] (via range(min(-5, 78)) being empty). Same reasoning applies -- CLI guards against it.
  • The test runner's except AssertionError on line 235 would silently swallow test failures if there were a typo, but it's spelled correctly and all tests pass. For future-proofing, pytest compatibility (already works via uv run pytest) is the safer runner.

@dguido dguido merged commit 40f192b into main Apr 1, 2026
6 checks passed
@dguido dguido deleted the let-fate-decide/secrets-randbelow branch April 1, 2026 14:49
tob-joe added a commit that referenced this pull request Apr 14, 2026
Adds two tests that would have caught the PR #125 regression fixed
by this PR (#144):

- test_cli_content_flag: runs `draw_cards.py --content 2` end-to-end,
  exercising the os.path calls in draw() that NameError'd without
  `import os`.
- test_os_imported_when_referenced: static guard that fails if
  draw_cards.py references `os.` without importing os.

Verified: both tests fail against the pre-#144 draw_cards.py
(along with most of the existing draw()/CLI tests — suggesting the
test suite was not actually running in CI when #125 merged; worth
a follow-up to wire it up).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tob-joe added a commit that referenced this pull request Apr 14, 2026
Adds test_cli_content_flag, which runs `draw_cards.py --content 2`
end-to-end -- the exact invocation path the agent uses -- and checks
the output JSON includes real card content (not a "file not found"
placeholder). This exercises the os.path calls in draw() that
NameError'd between PR #125 and PR #144.

Verified: fails against the pre-#144 draw_cards.py with
"name 'os' is not defined"; passes on the fix.

Note: most of the existing draw()/CLI tests in test_draw_cards.py
also fail on the broken version, so #125 would have been caught at
merge time if test_draw_cards.py were wired into CI. It isn't --
lint.yml runs pre-commit and bats, validate.yml checks frontmatter,
neither runs Python tests. Wiring it up is a separate change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tob-joe added a commit that referenced this pull request Apr 14, 2026
Discovers test_*.py / *_test.py files under plugins/ and executes
each one as a script. Matches the style of the existing bats job.

Works today for:
- plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py
- plugins/let-fate-decide/.../scripts/test_draw_cards.py

Both test files already exist in the repo but no CI job invoked them.
As a result, PR #125 (which broke let-fate-decide by removing
`import os` while leaving `os.path` calls in draw()) merged with
green CI even though the existing `test_draw_*` / `test_cli_*`
tests would have caught it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tob-joe added a commit that referenced this pull request Apr 15, 2026
Discovers test_*.py / *_test.py files under plugins/ and executes
each one as a script. Matches the style of the existing bats job.

Works today for:
- plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py
- plugins/let-fate-decide/.../scripts/test_draw_cards.py

Both test files already exist in the repo but no CI job invoked them.
As a result, PR #125 (which broke let-fate-decide by removing
`import os` while leaving `os.path` calls in draw()) merged with
green CI even though the existing `test_draw_*` / `test_cli_*`
tests would have caught it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dguido added a commit that referenced this pull request Apr 16, 2026
)

* ci: add python-tests job to run plugin Python test suites

Discovers test_*.py / *_test.py files under plugins/ and executes
each one as a script. Matches the style of the existing bats job.

Works today for:
- plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py
- plugins/let-fate-decide/.../scripts/test_draw_cards.py

Both test files already exist in the repo but no CI job invoked them.
As a result, PR #125 (which broke let-fate-decide by removing
`import os` while leaving `os.path` calls in draw()) merged with
green CI even though the existing `test_draw_*` / `test_cli_*`
tests would have caught it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci(python-tests): install aarch64 cross toolchain for constant-time-analysis

TestCrossArchitecture.test_cross_compile_arm64 invokes clang with
--target=aarch64-unknown-linux-gnu, which needs the aarch64 libc
headers. Without them clang fails with:
  fatal error: 'bits/libc-header-start.h' file not found

Install gcc-aarch64-linux-gnu + libc6-dev-arm64-cross so clang can
find the cross headers. Also install clang explicitly since the
runner may not have it preinstalled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: clarify python-tests workflow comments

Resolves code review findings on PR #147 (comment accuracy only —
no behavior change):

- Rewrite the toolchain dependencies comment. The previous wording
  ("aarch64 cross gcc pulls in libc6-dev-arm64-cross") implied a
  transitive dependency, but `--no-install-recommends` suppresses
  Recommends, so libc6-dev-arm64-cross is installed only because it
  is listed explicitly. New comment names what each package supplies.
- Document why `set -uo pipefail` deliberately omits -e (the loop
  collects per-file failures and exits with a combined code).

Reviewers (codex + gemini + pr-review-toolkit agents) flagged 13
findings total; 11 were dismissed (false positives, design choices
matching the bats job, or speculative). Quality pipeline (actionlint,
zizmor, shellcheck, pre-commit, plugin validators, both Python test
suites) all pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Dan Guido <dan@trailofbits.com>
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.

3 participants