Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .cursor/rules/cli-write-safety.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes
alwaysApply: true
---

# CLI Write Safety

Applies to **every command-line tool produced in this repo**, whatever the language or
argument parser: Click groups, `argparse` scripts under `tools/`, Django management
commands, TypeScript `bin` entries, and shell scripts.

## The three requirements

Any command that writes remote or local state, deletes, rotates a credential, or is
otherwise not trivially reversible must have all three:

1. **`--dry-run`** — runs the same code path, prints exactly what would change, exits 0,
and touches nothing. It is not a separate "plan" branch that can drift from the real
one; resolve the same targets and build the same payloads, then stop before the write.
2. **Confirmation by default** — print a summary of what is about to happen (counts,
target names, account/stack/environment), then prompt. Abort on anything but yes.
3. **`--yes`** (alias `-y`) — bypasses the prompt, and nothing else. It must never imply
or cancel `--dry-run`.

## Use these exact flag names

`--dry-run` and `--yes`. Do not invent `--apply`, `--submit`, `--commit`, `--force`,
`--no-confirm`, or `--for-real`.

Opt-in-to-write flags look safe but are not: they make the *safe* path the one the
operator has to remember, and every tool picks a different word for it, so muscle memory
from one tool does nothing for the next. Prompting by default gives the same protection
without depending on recall.

## Precedence and non-interactive runs

- `--dry-run` wins if both flags are passed.
- When a **real write** would occur, never silently proceed without explicit consent.
Without a TTY, or in `--json` mode, fail and tell the operator to pass `--yes`.
`--dry-run` skips this check because nothing is written.

## Read-only commands

Must not prompt, and must not offer `--dry-run` — there is nothing to preview.

## Where the flags live

Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write
command. `keys delete` in `packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py`
shows the confirmation half: it lists the keys it will delete, prompts by default, and
accepts `--yes`. It does not yet offer `--dry-run`, and its prompt goes to stdout; do not
copy those two gaps. The complete shape:

```python
@click.option("--dry-run", is_flag=True, default=False,
help="Show planned changes without submitting them.")
...
@click.option("--yes", is_flag=True, default=False, help="Skip the confirmation prompt.")
```

with a single guard called by every write path:

```python
def _confirm_or_abort(options, account, locations, *, skip: bool) -> None:
"""Require explicit consent before writing credentials."""
if skip or options.dry_run:
return

# Real write only; --dry-run returned above. --json has no interactive
# prompt to fall back on, so make the caller opt in rather than silently
# skipping the guard.
if options.as_json:
raise click.UsageError("--json requires --yes, which confirms the write.")

click.confirm(f"\nSet a new password for {account} in {len(locations)} location(s)?",
abort=True, err=True)
```

For interactive **real writes**, prompt on **stderr** (`err=True`) so piping stdout to
a file or `jq` still shows the question. Dry-run and `--yes` paths skip the prompt, so
`--dry-run --json` without `--yes` is valid.

`@click.confirmation_option(prompt=...)` gives you `--yes` for free and satisfies
requirements 2 and 3, but you still owe `--dry-run`.

For `argparse`, the equivalent is `--dry-run` / `--yes` store-true flags plus an explicit
`sys.stdin.isatty()` check before `input()` for real writes (not `--dry-run`); fail with
a message to pass `--yes` if non-interactive.

## Tests (Click / Python)

For Click-based Python CLIs, co-locate `test_*.py` per the repo's testing convention.
Two cases at minimum, using Click's `CliRunner`:

- `--dry-run` performs no writes (assert the client/session mock was never called).
- `--yes` skips the prompt (assert the command succeeds with no stdin).

TypeScript, shell, and Django commands must still prove the same three requirements,
using that language's test tooling and conventions.
2 changes: 1 addition & 1 deletion .cursor/rules/gitignore.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/communit
Ignore LangGraph artifacts.

```sh
curl https://raw.githubusercontent.com/mattnorris/gitignore/refs/heads/langchain/LangChain.gitignore >> .gitignore
curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/LangChain.gitignore >> .gitignore
```
4 changes: 2 additions & 2 deletions .cursor/rules/pulumi.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ This creates a `.pulumi/` directory in the current package for state storage.

## Key Points

- State lives in `.pulumi/` at the package level (e.g., `tools/python/aws-artifact-repo/.pulumi/`)
- State lives in `.pulumi/` at the package level (e.g., `tools/python/langsmith-hosting/.pulumi/`)
- Do NOT use `~/.pulumi/` (default) or workspace root for state
- Each Pulumi project manages its own state independently
- Use `uv run pulumi` to ensure correct Python environment

## Example Workflow

```bash
cd tools/python/aws-artifact-repo
cd tools/python/langsmith-hosting
uv run pulumi login file://.
uv run pulumi stack init dev
uv run pulumi config set aws:region us-east-1
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ repos:
entry: uv run pylint --load-plugins=perflint
language: system
types: [python]
exclude: ^.*/(migrations|tests|__pycache__|\.venv|venv)/.*
exclude: ^.*/(migrations|tests|__pycache__|\.venv|venv)/.*|.*/test_[^/]*\.py
pass_filenames: true
args: ["--ignore=migrations,tests,__pycache__,.venv,venv"]
stages: [pre-push]
Expand Down
107 changes: 107 additions & 0 deletions packages/python/ess-passwords/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# ess-passwords

Readable passwords from a filtered EFF wordlist — the kind you can read
aloud on a call without wincing.

```bash
$ ess-passwords generate
swept-staunch-circling-showdown-SCREEN
(63 bits of entropy from 6,131 candidate words)
```

The password goes to stdout and the entropy note to stderr, so it pipes
straight into a secret store:

```bash
ess-passwords generate | uv run pulumi config set myproj:dbPassword --secret
```

From Python:

```python
from ess_passwords import PasswordOptions, generate_password

generated = generate_password(PasswordOptions(word_count=6, digit_count=4))
print(generated.value) # VOCATION-paddle-move-bonding-overrate-scruffy-2810
print(generated.entropy_bits) # 88.8
```

## Installation

From the workspace root:

```bash
uv sync --all-packages
```

A plain `uv sync` will not put the `ess-passwords` command on `PATH`.

## CLI

### `ess-passwords generate`

Prints one password per line. Read-only — it writes nothing, so there is
no `--dry-run` and no confirmation prompt.

| Flag | Default | Description |
|---|---|---|
| `--words` | `5` | Number of words in the password |
| `--separator` | `-` | String joining the words |
| `--max-length` | none | Regenerate until the password fits in this many characters |
| `--capitalize-at` | random | Position of the upper-cased word, counting from 0 |
| `--digits` | `0` | Digits to append after the last word |
| `--count` / `-n` | `1` | Number of passwords to print |
| `--json` | off | Emit JSON: an object, or an array when `--count` is above 1 |

## API

### `generate_password(options=None) -> GeneratedPassword`

Generates one password. Exactly one word is upper-cased. The result
carries no date, symbol, or other tail — append whatever the consuming
system's complexity rules need, and size `max_length` to leave room for
it. Raises `PasswordPolicyError` when the options cannot produce a
password.

### `PasswordOptions`

Frozen dataclass. Every field is optional.

| Field | Default | Description |
|---|---|---|
| `word_count` | `5` | Number of words in the password |
| `separator` | `"-"` | String joining the words, and the digits when asked for |
| `max_length` | `None` | Reject and regenerate passwords longer than this |
| `capitalized_index` | `None` | Position of the one upper-cased word, from 0. `None` picks one at random per password |
| `digit_count` | `0` | Digits appended after the last word |

### `GeneratedPassword`

Frozen dataclass with `value`, `entropy_bits`, and `wordlist_size`.
`entropy_bits` counts the random word and digit choices only. Which word
is capitalised is not counted: it is worth just `log2(word_count)`
against a scheme an attacker already knows, and understating strength is
the safe direction.

### `load_wordlist()` and `excluded_words()`

The candidate words, and the profanity and sexual terms filtered out of
them. Entropy is derived from the filtered list, so exclusions are
reflected in reported strength automatically. `load_wordlist()` reads
and filters the EFF file once per process and returns the same immutable
tuple thereafter; word selection is per password, so this does not make
passwords repeat. If a crude word slips through, add it to
`_EXCLUDED_WORDS` in [`wordlist.py`](src/ess_passwords/wordlist.py)
rather than regenerating a password by hand.

## Running tests

```bash
uv run pytest packages/python/ess-passwords
```

## License

Generation uses
[xkcdpass](https://github.com/redacted/XKCD-password-generator)
(BSD-3-Clause), which bundles the EFF long wordlist.
28 changes: 28 additions & 0 deletions packages/python/ess-passwords/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[project]
name = "ess-passwords"
version = "0.1.0"
description = "Readable password generation from a filtered EFF wordlist"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"click>=8.1",
"xkcdpass>=1.30",
]

[dependency-groups]
dev = [
"pytest>=8.0",
]

[project.scripts]
ess-passwords = "ess_passwords.__main__:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build]
exclude = ["**/test_*.py"]

[tool.hatch.build.targets.wheel]
packages = ["src/ess_passwords"]
23 changes: 23 additions & 0 deletions packages/python/ess-passwords/src/ess_passwords/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Readable password generation from a filtered EFF wordlist."""

from .exceptions import EssPasswordsError, PasswordPolicyError
from .password import (
DEFAULT_SEPARATOR,
DEFAULT_WORD_COUNT,
GeneratedPassword,
PasswordOptions,
generate_password,
)
from .wordlist import excluded_words, load_wordlist

__all__ = [
"DEFAULT_SEPARATOR",
"DEFAULT_WORD_COUNT",
"EssPasswordsError",
"GeneratedPassword",
"PasswordOptions",
"PasswordPolicyError",
"excluded_words",
"generate_password",
"load_wordlist",
]
Loading
Loading