Skip to content

Commit 40f192b

Browse files
tob-joedguidoclaudetob-scott-a
authored
let-fate-decide: replace os.urandom with secrets.randbelow (#125)
* galvanize(iter1): replace os.urandom with secrets.randbelow, add tests 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. * Bump version to 1.1.0 and fix stale rejection sampling claim - 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> * Fix ruff format: collapse line continuation in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Merge Scott's PR #128 improvements + vivisect fixes 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> * let-fate-decide: improve performance by avoiding text-only turns --------- Co-authored-by: Dan Guido <dan@trailofbits.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Scott Arciszewski <scott.arciszewski@trailofbits.com>
1 parent 635e186 commit 40f192b

File tree

4 files changed

+276
-47
lines changed

4 files changed

+276
-47
lines changed

plugins/let-fate-decide/README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# let-fate-decide
22

3-
A Claude Code skill that draws Tarot cards using `os.urandom()` to inject
3+
A Claude Code skill that draws Tarot cards using `secrets` to inject
44
entropy into vague or underspecified planning decisions.
55

66
## What It Does
@@ -12,16 +12,17 @@ spread and uses the reading to inform its approach.
1212

1313
## Triggers
1414

15-
- Vague or underspecified prompts where multiple approaches are equally valid
16-
- "I'm feeling lucky"
17-
- "Let fate decide"
18-
- Nonchalant delegation ("whatever you think", "surprise me", "dealer's choice")
19-
- Yu-Gi-Oh references ("heart of the cards", "I believe in the heart of the cards")
15+
- Vague or ambiguous prompts where multiple reasonable approaches exist
16+
- "I'm feeling lucky", "let fate decide", "dealer's choice", "surprise me", "whatever you think", "YOLO"
17+
- Casual delegation ("whatever", "up to you", "your call", "idk", "just do something", "wing it", "I trust you", "doesn't matter", "do what you want", "I don't care", "any approach works", "you pick")
18+
- Yu-Gi-Oh references ("heart of the cards", "I believe in the heart of the cards", "you've activated my trap card", "it's time to duel")
19+
- Shrug-like brevity -- very short prompts that fully delegate the decision
20+
- About to arbitrarily pick between 2+ valid approaches (draw cards instead)
2021
- "Try again" on a system with no actual changes (redraw)
2122

2223
## How It Works
2324

24-
1. A Python script uses `os.urandom()` to perform a Fisher-Yates shuffle
25+
1. A Python script uses `secrets` to perform a Fisher-Yates shuffle
2526
2. 4 cards are drawn from the top of the shuffled deck
2627
3. Each card has an independent 50% chance of being reversed
2728
4. Claude reads the drawn cards' meaning files and interprets the spread

plugins/let-fate-decide/skills/let-fate-decide/SKILL.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: let-fate-decide
3-
description: "Draws 4 Tarot cards using os.urandom() to inject entropy into planning when prompts are vague or underspecified. Interprets the spread to guide next steps. Use when the user is nonchalant, feeling lucky, says 'let fate decide', makes Yu-Gi-Oh references ('heart of the cards'), demonstrates indifference about approach, or says 'try again' on a system with no changes. Also triggers on sufficiently ambiguous prompts where multiple approaches are equally valid."
3+
description: "Draws 4 Tarot cards to inject entropy into planning when prompts are vague, ambiguous, or casually delegated. Interprets the spread to guide next steps. Use when the user says 'let fate decide', 'YOLO', 'whatever', 'idk', or other nonchalant phrases, makes Yu-Gi-Oh references, or when you are about to arbitrarily pick between multiple reasonable approaches. Prefer over ask-questions-if-underspecified when the user's tone is casual or playful rather than precision-seeking."
44
allowed-tools:
55
- Bash
66
- Read
@@ -29,29 +29,30 @@ When the path forward is unclear, let the cards speak.
2929

3030
## When to Use
3131

32-
- **Vague prompts**: The user's request is ambiguous and multiple valid approaches exist
33-
- **Explicit invocations**: "I'm feeling lucky", "let fate decide", "dealer's choice", "surprise me", "whatever you think"
32+
- **Vague prompts**: The user's request is ambiguous and multiple reasonable approaches exist
33+
- **Explicit invocations**: "I'm feeling lucky", "let fate decide", "dealer's choice", "surprise me", "whatever you think", "YOLO"
34+
- **Casual delegation**: "whatever", "up to you", "your call", "idk", "just do something", "wing it", "I trust you", "doesn't matter", "do what you want", "I don't care", "any approach works", "you pick"
3435
- **Yu-Gi-Oh energy**: "Heart of the cards", "I believe in the heart of the cards", "you've activated my trap card", "it's time to duel"
35-
- **Nonchalant delegation**: The user expresses indifference about the approach
36+
- **Shrug-like brevity**: Very short prompts that fully delegate the decision without expressing a preference
3637
- **Redraw requests**: "Try again" or "draw again" when no actual system changes occurred (this means draw new cards, not re-run the same approach)
37-
- **Tie-breaking**: When you genuinely cannot decide between equally valid approaches
38+
- **Tie-breaking**: When you are about to arbitrarily pick between 2+ valid approaches, draw cards instead of silently choosing one
3839

3940
## When NOT to Use
4041

4142
- The user has given clear, specific instructions
4243
- The task has a single obvious correct approach
4344
- Safety-critical decisions (security, data integrity, production deployments)
4445
- The user explicitly asks you NOT to use Tarot
45-
- A more specific skill (like `ask-questions-if-underspecified`) would better serve the user by gathering actual requirements
46+
- The user's tone is precision-seeking rather than casual -- use `ask-questions-if-underspecified` instead to gather actual requirements
4647

4748
## How It Works
4849

4950
### The Draw
5051

51-
The script uses `os.urandom()` for cryptographic randomness:
52+
The script uses `secrets` for cryptographic randomness:
5253

5354
1. Builds a standard 78-card Tarot deck (22 Major Arcana + 56 Minor Arcana)
54-
2. Performs a Fisher-Yates shuffle using rejection sampling (no modulo bias)
55+
2. Performs a Fisher-Yates shuffle via `secrets.randbelow()` (no modulo bias)
5556
3. Draws 4 cards from the top
5657
4. Each card independently has a 50% chance of being reversed
5758

@@ -85,6 +86,10 @@ Key rules:
8586
- Major Arcana cards carry more weight than Minor Arcana
8687
- The spread tells a story across all 4 positions; don't interpret cards in isolation
8788
- Map abstract meanings to concrete technical decisions
89+
- **Never output interpretation as a text-only turn.** Include the
90+
interpretation alongside your next tool call (the action that
91+
implements the chosen option). Read all 4 cards in parallel if
92+
possible.
8893

8994
## Example Session
9095

plugins/let-fate-decide/skills/let-fate-decide/scripts/draw_cards.py

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Draw Tarot cards using os.urandom() for cryptographic randomness.
2+
"""Draw Tarot cards using the secrets module for cryptographic randomness.
33
44
Shuffles a full 78-card deck via Fisher-Yates and draws 4 from the top.
55
Each card has an independent 50/50 chance of being reversed.
@@ -10,10 +10,10 @@
1010
# ///
1111

1212
import json
13-
import os
13+
import secrets
1414
import sys
1515

16-
MAJOR_ARCANA = [
16+
MAJOR_ARCANA = (
1717
("major", "00-the-fool"),
1818
("major", "01-the-magician"),
1919
("major", "02-the-high-priestess"),
@@ -36,9 +36,9 @@
3636
("major", "19-the-sun"),
3737
("major", "20-judgement"),
3838
("major", "21-the-world"),
39-
]
39+
)
4040

41-
RANKS = [
41+
RANKS = (
4242
"ace",
4343
"two",
4444
"three",
@@ -53,9 +53,9 @@
5353
"knight",
5454
"queen",
5555
"king",
56-
]
56+
)
5757

58-
SUITS = ["wands", "cups", "swords", "pentacles"]
58+
SUITS = ("wands", "cups", "swords", "pentacles")
5959

6060

6161
def build_deck():
@@ -67,42 +67,23 @@ def build_deck():
6767
return deck
6868

6969

70-
def secure_randbelow(n):
71-
"""Return a cryptographically random integer in [0, n).
72-
73-
Uses os.urandom() with rejection sampling to avoid modulo bias.
74-
"""
75-
if n <= 0:
76-
raise ValueError("n must be positive")
77-
if n == 1:
78-
return 0
79-
# Number of bytes needed to cover range
80-
k = (n - 1).bit_length()
81-
num_bytes = (k + 7) // 8
82-
# Rejection sampling: discard values >= n to avoid bias
83-
while True:
84-
raw = int.from_bytes(os.urandom(num_bytes), "big")
85-
# Mask to k bits to reduce rejection rate
86-
raw = raw & ((1 << k) - 1)
87-
if raw < n:
88-
return raw
89-
90-
9170
def fisher_yates_shuffle(deck):
92-
"""Shuffle deck in-place using Fisher-Yates with os.urandom()."""
71+
"""Shuffle deck in-place using Fisher-Yates with secrets.randbelow()."""
9372
for i in range(len(deck) - 1, 0, -1):
94-
j = secure_randbelow(i + 1)
73+
j = secrets.randbelow(i + 1)
9574
deck[i], deck[j] = deck[j], deck[i]
9675
return deck
9776

9877

9978
def is_reversed():
100-
"""Return True with 50% probability using os.urandom()."""
101-
return os.urandom(1)[0] & 1 == 1
79+
"""Return True with 50% probability using secrets.randbits()."""
80+
return secrets.randbits(1) == 1
10281

10382

10483
def draw(n=4, include_content=False):
10584
"""Shuffle deck and draw n cards, each possibly reversed."""
85+
if not isinstance(n, int) or isinstance(n, bool):
86+
raise TypeError(f"n must be int, got {type(n).__name__}")
10687
deck = build_deck()
10788
fisher_yates_shuffle(deck)
10889
# Resolve cards directory relative to this script

0 commit comments

Comments
 (0)