Skip to content

feat: /secret-generate — the CSPRNG half of the secret flow (v1.43.0) - #61

Merged
lapc506 merged 2 commits into
mainfrom
feat/secret-generate
Aug 3, 2026
Merged

feat: /secret-generate — the CSPRNG half of the secret flow (v1.43.0)#61
lapc506 merged 2 commits into
mainfrom
feat/secret-generate

Conversation

@lapc506

@lapc506 lapc506 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The gap

The toolkit had three secret commands and all three assumed the value already
existed somewhere:

command what it does
/secret-input a human types a value into an OS dialog → staged
/secret-use one command consumes it as an env var
/secret-clear shred

Nothing made one. And the obvious substitute defeats the other three:
openssl rand -base64 32 prints the value on stdout, so when an agent runs it
that stdout is in the agent's context and in the session transcript on disk — a
place the value cannot be recalled from. The whole reason /secret-input reaches
for a GUI dialog is to keep a typed secret out of exactly those places.

The invariant, and why it is structural

The generated value never goes to stdout.

Not "be careful not to print it" — the script is arranged so it cannot. The value
is written straight into the same staged file /secret-input uses
(generate … >"$SECRET_PATH"), is never assigned to a variable at the script's
top scope
, and is shown only in a GUI window a human is looking at. Every later
line re-reads the file for metadata, so there is exactly one place it lives.

stdout carries this and nothing else:

staged   /run/user/1000/mnm-secret
length   40 characters
alphabet 89 symbols (upper=1 lower=1 digits=1 symbols=1)
entropy  ~259 bits

Controls, run in both directions

A verification that cannot fail proves nothing, so the leak check was run against
a deliberately sabotaged copy first:

control result
real script — is the value in stdout? no
sabotaged copy (a leaking echo appended) yes — the check finds it
staged file 40 bytes, mode 600, on tmpfs
--fingerprint on the same value twice identical
--fingerprint after regenerating different
--fingerprint with nothing staged exit 1, message on stderr
shellcheck scripts/secret-generate.sh clean

Three decisions worth reading rather than inferring

Rejection sampling, not byte % n. 256 is not a multiple of most alphabet
sizes, so the modulo makes the first 256 mod n symbols more likely — for the
89-symbol default, 78 of them at about 1.35x. Small, real, avoidable for the
price of a loop. /dev/urandom throughout, never $RANDOM, which is a 15-bit
generator seeded from pid and time.

The symbol class excludes the quote, backtick and backslash characters. A
generated secret gets pasted into shell one-liners, JSON and YAML, and a backtick
inside a double-quoted shell string is command substitution. A generator that can
emit a value its consumer cannot safely carry fails at 3am, in a way that looks
like the consumer's bug.

--fingerprint answers the question a secret store cannot. A store will not
read a value back — that is the point of it — so after loading the same value
into two of them nothing confirms they match. A truncated SHA-256 settles it with
neither end printing anything sensitive; same move as an SSH key fingerprint.

It is labelled in the code as what it is: a comparison handle, not encryption
and not protection. SHA-256 is one-way, so it cannot be turned back into the
secret — and equally it cannot make an exposed secret safe. And the case where it
is unsafe is named rather than left implicit: for a human-chosen password the
hash IS dictionary-attackable
, which is precisely what a salt exists for. It is
safe here only because the input came from a CSPRNG with the entropy printed
above.

Also carried over

The control /secret-input already applies to typed input: it refuses to stage
an empty value.
An empty staged secret is indistinguishable from a good one to
every consumer downstream, and that is how an empty value reaches a store and
then reads as configured.

Known limits, stated rather than discovered

  • The GUI needs zenity (Linux). Without it the script says so and names the
    headless form — it does not silently fall back to a different alphabet or
    length.
  • No PowerShell twin. secret-store.ps1 exists for native Windows;
    secret-generate is bash only, so Windows without Git Bash/WSL/MSYS2 has no
    /secret-generate. The command doc says this plainly and points at
    /secret-input instead, rather than suggesting a workaround.
  • have() uses the plain command -v probe form already in this repo's
    Makefile. The reverse redirect order is flagged by shellcheck as SC2069, and
    command -v writes nothing to stderr on a miss, so nothing diagnostic is lost
    at that one call site.

Files

file
scripts/secret-generate.sh new, 755
commands/secret-generate.md new
README.md one row in the command table
CHANGELOG.md 1.43.0
package.json · .claude-plugin/plugin.json · .claude-plugin/marketplace.json 1.42.01.43.0

Created by Claude Code on behalf of @lapc506

The toolkit had three secret commands and all three assumed the value already
existed somewhere: a human types it (/secret-input), one command consumes it
(/secret-use), shred removes it (/secret-clear). Nothing MADE one.

That gap is not cosmetic, because the obvious substitute defeats the other three.
`openssl rand -base64 32` prints the value on stdout, and when an agent runs it
that stdout lands in the agent's context and in the session transcript on disk —
a place the value cannot be recalled from. The whole reason /secret-input reaches
for an OS-native dialog is to keep a typed secret out of exactly those places.

So the invariant is structural rather than a matter of care: THE GENERATED VALUE
NEVER GOES TO STDOUT. It is written straight into the same staged file
/secret-input uses, is never assigned to a variable at the script's top scope so
no later line can print it by accident, and is shown only in a GUI window a human
is looking at. stdout carries length, alphabet size and entropy.

Both controls run, in both directions:
  - real script:      value absent from stdout, 40 chars staged at 0600
  - sabotaged copy:   the check FINDS the value in stdout
  - fingerprint:      stable for one value, different for another
  - shellcheck:       clean

Three decisions worth reading in the diff rather than inferring:

  Rejection sampling, not `byte % n`. 256 is not a multiple of most alphabet
  sizes, so the modulo makes the first `256 mod n` symbols more likely — for the
  89-symbol default, 78 of them at ~1.35x. /dev/urandom throughout, never
  $RANDOM, which is a 15-bit generator seeded from pid and time.

  The symbol class excludes ' " ` \ deliberately. The value gets pasted into
  shell one-liners, JSON and YAML, and a backtick inside a double-quoted shell
  string is command substitution.

  --fingerprint answers the question a secret store cannot: a store will not read
  a value back, so after loading the same value into two of them nothing confirms
  they match. A truncated SHA-256 settles it with neither end printing anything
  sensitive. Labelled in the code as a comparison handle — NOT encryption, NOT
  protection — and the case where it is unsafe is named rather than left
  implicit: for a human-chosen password the hash IS dictionary-attackable, which
  is what a salt exists for.

Also carries the control /secret-input already had: it refuses to stage an empty
value. An empty staged secret is indistinguishable from a good one to every
consumer downstream.

The `have()` probe uses the plain form already in this repo's Makefile, because
shellcheck flags the reverse order as SC2069 and `command -v` writes nothing to
stderr on a miss — so nothing diagnostic is lost at that one call site.

@dojo-code-reviewer dojo-code-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Changes Requested

Changes requested — 1 blocker, 2 P2, 1 P3. Confidence: 1.00/5.00.

Walkthrough

⚠️ GOVERNANCE WARNING: This PR targets the main branch directly. According to GitFlow conventions, feature branches should target develop, which is then merged into main during a release cycle. Since this is an informative warning, the final review verdict remains strictly based on the technical code analysis.

Walkthrough of Changes

This pull request introduces /secret-generate, a CSPRNG-driven command-line generator for minting random staging secrets. It utilizes /dev/urandom and rejection sampling to avoid modulo bias, outputs metadata (length, alphabet, entropy) to stdout, and displays the generated secret in a secure GUI window using zenity. It also includes --status and --fingerprint (truncated SHA-256 handle) commands. The package versions are bumped to 1.43.0 across configurations.

Areas Reviewed

  • scripts/secret-generate.sh — Checked security parameters, CSPRNG rejection sampling loop, zenity window integration, permissions, and argument parsing.
  • commands/secret-generate.md — Reviewed technical specifications, rules, and Spanish localization requirements.
  • scripts/secret-store.sh — Used as design reference to check staging directory consistency and permission handling.

Safety Rationale

The PR is not safe to merge yet because the plaintext secret is passed directly to zenity --entry via command-line arguments (--entry-text). This exposes the sensitive secret value to the system process table (visible via ps or /proc), directly violating the project's security constraints and the script's own Absolute Rules.

Changes requested — 1 blocker, 2 P2s, 1 P3.

🔴 P1 — Blockers

  • scripts/secret-generate.sh:216 — 🔴 P1 (blocker) — Passing the plaintext secret as a command-line argument (--entry-text="$(cat "$SECRET_PATH")") exposes it in the system's process table (e.g., via ps aux or /proc/<pid>/cmdline), where any local user or monitoring daemon can read it. This directly violates the codebase's own Absolute Rules (Reglas absolutas) documented in commands/secret-generate.md: "Nunca lo pases como argumento de otro comando. Un argv es legible por cualquier proceso vía /proc y queda en el trace del shell."

To fix this securely without exposing the secret to the process table, we can pipe the secret and metadata into standard input of zenity --text-info. This avoids command line exposure entirely. We also save and check the exit code of zenity so that if the window is closed/cancelled by the user (via X or Esc) or if a GUI error occurs, the script safely aborts instead of spinning in an infinite loop.

[pass 1]

🟡 P2 — Major

  • scripts/secret-generate.sh:171 — 🟡 P2 (major) — Running od and tr in a subshell for every single byte of the secret is a massive performance bottleneck. Spawning 2 processes per byte (e.g., 64 processes for a 32-character secret, or 256 processes for 128 characters) can make the script take several seconds to execute, especially on systems with slow process creation overhead (such as macOS, WSL, or Git Bash on Windows).

We can optimize this by reading random bytes in batches of size want * 2 in a single od invocation, then processing those bytes from an in-memory array in Bash. Rejection sampling is preserved perfectly, but the number of external processes spawned is reduced to exactly one.

[pass 1]

  • scripts/secret-generate.sh:199 — 🟡 P2 (major) — In environments with set -euo pipefail active, running chmod 600 "$SECRET_PATH" directly can cause the script to immediately crash and terminate if the target filesystem does not support POSIX permissions (such as shared host mounts in Docker, or NTFS/FAT partitions on Git Bash/WSL/MSYS2).

To ensure parity with how secret-store.sh handles file permissions, we should ignore errors from this command by appending 2>/dev/null || true.

[pass 1]

🔵 P3 — Minor

  • scripts/secret-generate.sh:152 — 🔵 P3 (minor) — bc is not part of the standard POSIX core utilities and is frequently missing from minimal Linux distributions, slim container images, and bare environments. If bc is missing, the script will crash under set -e.

We can calculate the entropy bits using standard awk (specifically the built-in log function), which is standard POSIX and guaranteed to be present on all Linux/macOS/Git Bash systems.

[pass 1]


Total findings: 1 security, 2 compliance, 1 business context (4 total)

case "$sel" in *Uppercase*) USE_UPPER=1 ;; *) USE_UPPER=0 ;; esac
case "$sel" in *Lowercase*) USE_LOWER=1 ;; *) USE_LOWER=0 ;; esac
case "$sel" in *Digits*) USE_DIGIT=1 ;; *) USE_DIGIT=0 ;; esac
case "$sel" in *Symbols*) USE_SYMBOL=1 ;; *) USE_SYMBOL=0 ;; esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 P3 (minor) — bc is not part of the standard POSIX core utilities and is frequently missing from minimal Linux distributions, slim container images, and bare environments. If bc is missing, the script will crash under set -e.

We can calculate the entropy bits using standard awk (specifically the built-in log function), which is standard POSIX and guaranteed to be present on all Linux/macOS/Git Bash systems.

[pass 1]

LC_ALL=C printf '%.0f' "$(echo "$1 * l($2) / l(2)" | bc -l)"
}

# ---------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 P2 (major) — Running od and tr in a subshell for every single byte of the secret is a massive performance bottleneck. Spawning 2 processes per byte (e.g., 64 processes for a 32-character secret, or 256 processes for 128 characters) can make the script take several seconds to execute, especially on systems with slow process creation overhead (such as macOS, WSL, or Git Bash on Windows).

We can optimize this by reading random bytes in batches of size want * 2 in a single od invocation, then processing those bytes from an in-memory array in Bash. Rejection sampling is preserved perfectly, but the number of external processes spawned is reduced to exactly one.

[pass 1]

umask 077
mkdir -p "$(dirname "$SECRET_PATH")"

while :; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 P2 (major) — In environments with set -euo pipefail active, running chmod 600 "$SECRET_PATH" directly can cause the script to immediately crash and terminate if the target filesystem does not support POSIX permissions (such as shared host mounts in Docker, or NTFS/FAT partitions on Git Bash/WSL/MSYS2).

To ensure parity with how secret-store.sh handles file permissions, we should ignore errors from this command by appending 2>/dev/null || true.

[pass 1]

rm -f "$SECRET_PATH"
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 P1 (blocker) — Passing the plaintext secret as a command-line argument (--entry-text="$(cat "$SECRET_PATH")") exposes it in the system's process table (e.g., via ps aux or /proc/<pid>/cmdline), where any local user or monitoring daemon can read it. This directly violates the codebase's own Absolute Rules (Reglas absolutas) documented in commands/secret-generate.md: "Nunca lo pases como argumento de otro comando. Un argv es legible por cualquier proceso vía /proc y queda en el trace del shell."

To fix this securely without exposing the secret to the process table, we can pipe the secret and metadata into standard input of zenity --text-info. This avoids command line exposure entirely. We also save and check the exit code of zenity so that if the window is closed/cancelled by the user (via X or Esc) or if a GUI error occurs, the script safely aborts instead of spinning in an infinite loop.

[pass 1]

Opening the PR for /secret-generate returned `mergeable=MERGEABLE, 0 failures`
with ZERO checks having examined it. The only entry in the list was
`[code]smith=SKIPPED`.

Not a fluke and not a misconfiguration: `test-hooks.yml` filters on
`paths: hooks/** , scripts/build-rules.mjs`, and a new file under `scripts/`
matches neither. The repo had no lane that reads a shell script at all — while
shipping `secret-store.sh` at 248 lines handling credentials.

"0 failures" and "nothing ran" render identically in the checks list. That is the
whole reason for this file.

A RATCHET ON CHANGED FILES, not a whole-tree gate, and the reason is measured
rather than assumed. `shellcheck -S warning` over the 20 tracked .sh files:

  17 clean
   3 carrying 9 findings between them:
       hooks/cross-cutting/lib/jq-input.sh      4
       hooks/cross-cutting/lib/load-config.sh   3
       hooks/test-hooks.sh                      2

A whole-tree gate would open RED on its own repository on day one, and a gate
that reds the repo it guards is one that gets deleted or routed around inside a
week. Diff-scoped, it is green from the first run and every NEW script is held to
the standard.

The known cost of diff-scoping is stated in the file rather than left to be
discovered: an untouched file never gets re-checked. So a second job runs the
whole tree, prints every finding into the step summary, and FAILS if the count
grows past the 3 recorded — the backlog can shrink but cannot quietly expand.

Both jobs simulated locally, with the negative control:

  debt, BASELINE=3 -> passes  (failing=3)
  debt, BASELINE=2 -> FAILS   (the growth check can actually fail)
  changed          -> collects scripts/secret-generate.sh, shellcheck clean

Two details that would otherwise be silent defects:

  `--diff-filter=d` drops deletions. A removed file cannot be linted, and
  treating its absence as a failure is how a cleanup PR goes red.

  The zero-changed-files path emits an explicit `::notice::` saying it checked
  NOTHING. A job that found nothing and a job that looked at nothing must not
  read the same — which is the defect this workflow exists for, one level up.

Both SHAs reach the shell through `env:` and are read as `$VAR`, never
interpolated into a `run:` body.

TWO THINGS IN ONE PR, declared rather than left implicit. The feature is
/secret-generate; this is CI governance. They are folded together because the
governance gap was found BY the feature — the PR came back green having been
examined by nothing — and shipping the script under that condition is the thing
the script itself argues against. They share no code and revert independently.
@lapc506
lapc506 merged commit ac7d35d into main Aug 3, 2026
3 checks passed

@dojo-code-reviewer dojo-code-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved

Approved — 0 blockers, 3 P3. Confidence: 4.40/5.00.

Walkthrough

⚠️ Governance Warning: This PR targets the main branch directly. GitFlow expects feature branches to target develop before merging into main. This warning is non-blocking and purely informative for release coordination.

Walkthrough

This PR adds the /secret-generate command, completing the CSPRNG-based secret flow for the toolkit. It introduces a highly disciplined Bash script scripts/secret-generate.sh that securely generates random secrets via /dev/urandom and rejection sampling, keeps the secret entirely out of stdout, and integrates a selectable zenity GUI or headless fallback. It also introduces a robust linting ratchet with a Shellcheck CI workflow (.github/workflows/shellcheck.yml) to prevent shell scripting debt from growing.

Files Reviewed

  • scripts/secret-generate.sh (Entropy calculations, rejection sampling, CLI option parsing, and GUI window creation)
  • .github/workflows/shellcheck.yml (CI changed-file diff filter, whole-tree debt check, and rating baseline)
  • commands/secret-generate.md (Command documentation, Absolute rules, Spanish localization guidance)
  • README.md, CHANGELOG.md, package.json, .claude-plugin/* metadata (Command registry integration and version bumping)

Safety Rationale

The code is exceptionally well-engineered with robust security controls. It strictly adheres to the toolkit's design constraints: the generated secret is written directly to the memory-backed staging file (SECRET_PATH) and never assigned to top-level variables, printed to stdout, or passed as sub-process arguments. It also enforces umask 077 and permission 600 for file storage. Rejection sampling ensures perfectly uniform distribution, and /dev/urandom provides strong CSPRNG entropy.

Approved — 0 blockers, 3 P3.

🔵 P3 — Minor

  • scripts/secret-generate.sh:46 — 🔵 P3 (minor) — Headless positive class selection flags (like --digits) have no effect because all four classes default to 1 by default. Specifying positive flags should restrict selection to only those classes instead of keeping all of them active. To fix this, consider initializing the variables as empty strings, tracking if any positive flags were set during CLI parsing, and then resolving defaults dynamically (e.g., defaulting to 0 if other positive flags are specified, or 1 if none are).

[pass 1]

  • scripts/secret-generate.sh:90 — 🔵 P3 (minor) — Passing --length as the final option with no value causes an unbound variable error and an immediate exit under set -u when accessing $2. Checking the remaining argument count before shifting ensures a graceful error message instead of a crash.

[pass 1]

  • scripts/secret-generate.sh:119 — 🔵 P3 (minor) — The bc utility is missing in some minimal environments (such as stripped-down Docker containers), which will cause entropy_bits to fail and crash the script due to set -eo pipefail. Checking for bc availability and falling back to a safe placeholder prevents runtime failures.

[pass 1]


Total findings: 3 business context (3 total)

echo "/run/user/$(id -u)"
elif [ -n "${TMPDIR:-}" ]; then
echo "${TMPDIR%/}"
elif [ -n "${TEMP:-}" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 P3 (minor) — Headless positive class selection flags (like --digits) have no effect because all four classes default to 1 by default. Specifying positive flags should restrict selection to only those classes instead of keeping all of them active. To fix this, consider initializing the variables as empty strings, tracking if any positive flags were set during CLI parsing, and then resolving defaults dynamically (e.g., defaulting to 0 if other positive flags are specified, or 1 if none are).

[pass 1]

--lower) USE_LOWER=1; GUI=0; shift ;;
--digits) USE_DIGIT=1; GUI=0; shift ;;
--symbols) USE_SYMBOL=1; GUI=0; shift ;;
--no-symbols) USE_SYMBOL=0; GUI=0; shift ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 P3 (minor) — Passing --length as the final option with no value causes an unbound variable error and an immediate exit under set -u when accessing $2. Checking the remaining argument count before shifting ensures a graceful error message instead of a crash.

[pass 1]

# human-chosen password the hash IS dictionary-attackable and publishing
# it leaks; that case is exactly what a salt exists for. This is safe only
# because the value came from a CSPRNG with the entropy printed below.
[ -s "$SECRET_PATH" ] || { echo "no secret staged." >&2; exit 1; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 P3 (minor) — The bc utility is missing in some minimal environments (such as stripped-down Docker containers), which will cause entropy_bits to fail and crash the script due to set -eo pipefail. Checking for bc availability and falling back to a safe placeholder prevents runtime failures.

[pass 1]

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.

1 participant