enject 0.2.0-alpha

Hide .env secrets from AI agents. Stores secrets in an encrypted local store and injects them into subprocesses at runtime — your .env file contains only symbolic references, never real values.
# enject — Architecture

## What is enject?

`enject` is a CLI tool that prevents AI agents and other processes from reading secrets out of `.env` files by ensuring plaintext secrets **never exist on disk**. Instead, `.env` files contain only `en://` references. Secrets are decrypted from an encrypted local store and injected directly into a subprocess's environment at launch — they exist only in process memory, never in any file.

```
# .env (safe to commit, safe for AI agents to read)
DATABASE_URL=en://database_url
STRIPE_KEY=en://stripe_key
PORT=3000
```

```bash
enject run -- npm start
# prompts for master password → decrypts store → spawns subprocess with resolved env
# npm start sees real values; .env on disk never changes
```

---

## The Problem Being Solved

Tools like Claude Code can read `.env` files from the project directory, accidentally leaking API keys, database passwords, and other secrets into AI context. Even when AI tools claim not to read `.env`, there is no enforcement mechanism. The only reliable solution is to ensure `.env` files contain nothing worth leaking.

`enject` is the free, open-source, zero-dependency alternative to using `1password CLI` or similar commercial tools for this purpose.

---

## Mental Model

```
.env  (committed to git, safe to read)     .enject/store  (encrypted blob, gitignored)
──────────────────────────────────         ───────────────────────────────────────────
DATABASE_URL=en://database_url             AES-256-GCM encrypted JSON:
STRIPE_KEY=en://stripe_key                 { "database_url": "postgres://...",
PORT=3000                                    "stripe_key": "sk_live_..." }

                     ↓  enject run -- npm start

          1. prompt for master password
          2. derive key via Argon2id
          3. decrypt store in memory
          4. resolve en:// references
          5. zeroize key material
          6. exec subprocess with resolved env

          subprocess env:
          DATABASE_URL=postgres://user:pass@localhost/mydb
          STRIPE_KEY=sk_live_abc123
          PORT=3000
```

Plain `KEY=VALUE` pairs in `.env` pass through unchanged. Only `en://` references are resolved.

---

## Project Directory Layout

```
my-project/
├── .env                    # template file — en:// refs + plain values. Commit this.
├── .enject/
│   ├── config.toml         # backend type, KDF params, format version
│   └── store               # encrypted secrets blob. NEVER commit this.
└── .gitignore              # must include .enject/
```

A global store lives at `~/.enject/` for secrets shared across multiple projects. These are referenced with `en://global/key_name`.

---

## CLI Surface

```bash
enject init                  # initialize store in current directory, choose backend
enject set <key>             # add/update a secret (value prompted interactively, never as CLI arg)
enject list                  # list key names only, never values
enject delete <key>          # remove a secret from the store
enject run -- <cmd> [args]   # resolve .env → inject → exec subprocess
enject import <file>         # ingest a plaintext .env, encrypt all values, rewrite file as template
enject rotate                # re-encrypt store with a new master password
```

### Deliberately Omitted Commands

- **No `get`** — printing a secret value to stdout creates an AI-readable leakage vector
- **No `export`** — same reason; printing all resolved values defeats the purpose

If a user loses their master password, secrets must be re-added manually via `enject set`. This is intentional: there is no recovery path that doesn't involve decrypting the store.

---

## Secret Backend: Master Password (AES-256-GCM + Argon2id)

This is the only supported backend in v1. It is fully self-contained with no external dependencies.

### Encryption Scheme

```
config.toml contains:
  backend = "password"
  version = 1
  kdf = "argon2id"
  m_cost = 65536      # 64 MB memory
  t_cost = 3          # 3 iterations
  p_cost = 4          # 4 parallelism threads
  salt = "<hex>"      # 32 random bytes, generated at init, never changes

store file contains:
  nonce = first 12 bytes   # random, rotated on every write
  ciphertext = remaining bytes
```

### Runtime Decryption Flow

```
1. Read salt from config.toml
2. Prompt for master password via rpassword (never echoed, never in shell history)
3. argon2id(password, salt, m_cost, t_cost, p_cost) → 32-byte key
4. Read nonce from store file header (first 12 bytes)
5. aes-256-gcm decrypt(key, nonce, ciphertext) → plaintext JSON bytes
6. zeroize(key), zeroize(password) immediately
7. Parse JSON → HashMap<String, String>
```

### Plaintext Store Format (inside encryption)

```json
{
  "database_url": "postgres://user:pass@localhost/mydb",
  "stripe_key": "sk_live_abc123",
  "sendgrid_api_key": "SG.xxxx"
}
```

### Write Flow (set, delete, rotate)

```
1. Decrypt store → mutate HashMap → serialize to JSON
2. Generate fresh random 12-byte nonce
3. aes-256-gcm encrypt(key, nonce, JSON) → ciphertext
4. Write nonce + ciphertext atomically to store file
5. zeroize all key material
```

Nonce is rotated on every write to prevent nonce reuse — a critical AES-GCM security property.

---

## .env Template Parsing

### Rules

| Line format | Behavior |
|---|---|
| `# comment` | Ignored |
| `KEY=value` | Passed through as-is to subprocess env |
| `KEY=en://secret_name` | Resolved from local `.enject/store` |
| `KEY=en://global/secret_name` | Resolved from `~/.enject/store` |
| `KEY=en://unknown_key` | **Hard error** — enject refuses to run |
| Malformed line | **Hard error** — enject refuses to run |

### Error Philosophy

enject fails loudly rather than silently. If any `en://` reference cannot be resolved, the subprocess is never launched. Partial injection with some secrets missing is worse than no injection at all.

---

## subprocess Execution

`enject run` uses `std::process::Command` with an **explicitly constructed environment**:

- Starts from the OS baseline environment (so `PATH`, `HOME`, etc. are present)
- Adds all resolved `.env` values on top
- Does NOT inherit arbitrary parent shell variables unless they were in `.env`

This means the subprocess gets exactly what was declared in `.env`, resolved — no more, no less.

The `--` separator is required. Everything after `--` is passed verbatim to the OS as the command and arguments.

---

## Security Properties

### What enject protects against

- AI agents, editors, log scrapers, or any process reading `.env` from disk — they see only `en://` references
- Accidental commits of plaintext secrets to git
- Shell history exposure (values are never passed as CLI arguments)

### What enject does NOT protect against

- A compromised OS or process that can read `/proc/<pid>/environ` — once secrets are in a subprocess's environment, they're in memory. This is the same trust boundary as `op run` from 1Password CLI and is considered acceptable.
- An attacker with access to the encrypted store AND the master password

### Deliberate security decisions

- `set` accepts no value argument — value is always prompted interactively via `rpassword`
- Nonce rotates on every write — no AES-GCM nonce reuse
- `zeroize` is called on all key material immediately after use
- `get` and `export` commands do not exist
- Hard errors on missing references — no silent partial injection
- Store file has no plaintext headers — no key names visible without decryption

---

## Rust Crate Dependencies

| Purpose | Crate |
|---|---|
| CLI argument parsing | `clap` (derive API) |
| AES-256-GCM encryption | `aes-gcm` (RustCrypto) |
| Argon2id KDF | `argon2` (RustCrypto) |
| Secure memory zeroing | `zeroize`, `secrecy` |
| Interactive password prompt | `rpassword` |
| Serialization | `serde` + `serde_json` |
| Config file parsing | `toml` |
| Error handling | `anyhow` + `thiserror` |
| Random number generation | `rand` |

No async runtime. All I/O is synchronous, keeping the binary small and the trust surface minimal.

---

## Module Structure

```
src/
├── main.rs                  # entry point, clap dispatch
├── cli.rs                   # clap struct definitions and argument types
├── config.rs                # .enject/config.toml read/write, KDF params
├── store/
│   ├── mod.rs               # Store trait: open(), get(), set(), delete(), list()
│   └── password.rs          # AES-256-GCM + Argon2id implementation
├── env_template.rs          # .env file parsing, en:// reference extraction
├── runner.rs                # subprocess construction and exec
├── commands/
│   ├── init.rs              # enject init
│   ├── set.rs               # enject set <key>
│   ├── list.rs              # enject list
│   ├── delete.rs            # enject delete <key>
│   ├── run.rs               # enject run -- <cmd>
│   ├── import.rs            # enject import <file>
│   └── rotate.rs            # enject rotate
└── error.rs                 # thiserror error type definitions
```

The `Store` trait is the key abstraction boundary. `runner.rs` and all commands interact only with the trait — they have no knowledge of the encryption implementation.

---

## Future Considerations (Out of Scope for v1)

- **Age encryption backend** — for teams wanting to commit encrypted stores to git with per-developer keypairs
- **Shell completions** — `clap` can generate these trivially once the CLI is stable  
- **Session caching** — avoid re-prompting for password within a terminal session (requires careful design around security tradeoffs)
- **Other config file formats** — for `database.yml`, `config.toml` etc.; deferred because it requires templated file resolution with temporary plaintext files, which complicates the security model