secretsh 0.2.1

Secure subprocess secret injection for AI agents
Documentation
# AGENTS.md

## What this is

secretsh is a single-crate Rust binary + library that injects secrets from a `.env` file into subprocess argv and redacts them from output. It targets macOS and Linux.

## Documentation

| Document | Content |
|----------|---------|
| [docs/architecture.md]docs/architecture.md | Execution pipeline, module map, platform spawning, memory hardening, signal handling, redaction |
| [docs/tokenizer.md]docs/tokenizer.md | Quoting rules, metacharacter rejection, placeholder syntax, error cases |
| [docs/cli.md]docs/cli.md | Full CLI reference, flags, exit codes |
| [docs/threat-model.md]docs/threat-model.md | Security model, oracle attacks, known limitations |
| [docs/python-api.md]docs/python-api.md | Python package API (`secretsh.run()`) |
| [examples/]examples/ | Runnable examples |

## Build & verify

```bash
cargo build                          # dev build
cargo test                           # all unit tests (~1s)
cargo clippy -- -D warnings          # must be zero warnings (CI enforces -D)
cargo fmt --check                    # rustfmt.toml: max_width=100
cargo build --release                # LTO + strip
```

CI runs: `check` → `fmt` → `clippy` → `test`. All must pass on PR.

## Critical conventions

- **All secret bytes must be `Zeroizing<Vec<u8>>`**. Never use `String` or plain `Vec<u8>` for secret material.
- **Argv elements passed to `spawn_child` must be null-terminated `Zeroizing<Vec<u8>>`**. The spawn module does not add terminators.
- **`spawn.rs` is macOS-specific** — it uses `posix_spawnp` only.

## Tokenizer changes are high risk

The tokenizer is the security boundary between agent-generated input and process execution. Any change to `tokenizer.rs` must:
1. Include tests for the specific edge case
2. Verify all metacharacter rejection tests still pass
3. Be fuzz-tested before merge

**Currently rejected unquoted:** `|`, `&`, `;`, `` ` ``, `(`, `*`, `$` (when followed by alphanumeric/`_`/`{`/`(`).

**Currently allowed unquoted (literal bytes — no shell expansion occurs):** `?`, `<`, `>`, `[`.

`|`, `&`, `;` are rejected not for security reasons (posix_spawnp never interprets them) but to prevent silent wrong behaviour: an agent writing `curl ... | jq .` would get `|` passed as a literal arg to curl with no pipe created and no error.

## E2E smoke test

```bash
echo 'MY_SECRET=hunter2' > /tmp/test.env

# Basic injection + redaction
./target/release/secretsh --env /tmp/test.env run --quiet -- echo '{{MY_SECRET}}'
# Output: [REDACTED_MY_SECRET]

# --no-shell blocks shell interpreters
./target/release/secretsh --env /tmp/test.env run --quiet --no-shell -- echo '{{MY_SECRET}}'
# Output: [REDACTED_MY_SECRET]
./target/release/secretsh --env /tmp/test.env run --no-shell -- sh -c "'echo {{MY_SECRET}}'" 2>&1
# Output: secretsh error: spawn error: shell delegation blocked: "sh" ...

# Unresolved key lists available keys
./target/release/secretsh --env /tmp/test.env run --quiet -- echo '{{MISSING_KEY}}' 2>&1
# Output: secretsh error: placeholder error: "MISSING_KEY" not found in env file; available keys: [MY_SECRET]

# Real API call (command passed as single string — inner quotes reach tokenizer)
./target/release/secretsh --env .env run --quiet --no-shell -- \
  "curl -sS -H 'X-Api-Key: {{NINJA_API_KEY}}' 'https://api.api-ninjas.com/v2/quoteoftheday'"
# Output: [{"quote": "...", "author": "...", ...}]

# Pipe via parent shell (not inside command string)
./target/release/secretsh --env .env run --quiet --no-shell -- \
  curl -sS -H 'X-Api-Key:\ {{NINJA_API_KEY}}' 'https://api.api-ninjas.com/v2/quoteoftheday' | jq '.'
```

## Security conventions

- **`--no-shell` is recommended for all AI-agent contexts.** It blocks known shell interpreters by argv[0] basename, preventing shell conditional oracle attacks.
- **Output redaction is substring matching** — false positives occur when a secret value (e.g. `123456`) appears in unrelated output. This is a known design limitation with no fix within the current model.
- See [docs/threat-model.md]docs/threat-model.md for a full breakdown of what is and is not protected.

## Releasing a new version

Update the version in **all files** before tagging:

| File | Field |
|------|-------|
| `Cargo.toml` | `version = "X.Y.Z"` |
| `pyproject.toml` | `version = "X.Y.Z"` |
| `CHANGELOG.md` | Add `## [X.Y.Z] - YYYY-MM-DD` section with comparison link |

After updating, regenerate `Cargo.lock`:

```bash
cargo generate-lockfile
```

Commit files, merge to main, then tag and release:

```bash
git tag vX.Y.Z
git push origin vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes
```

This triggers: `release-binaries` → `publish-crates`.