s2-simple-secrets 1.2.0

Simple Secrets — inject secrets into subprocesses without ambient environment exposure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
```
       .-------.
      /         \
     |           |
  .--+-----------+--.
  |                  |
  |      //S2        |
  |  Simple Secrets  |
  |                  |
  '------------------'
```

Inject secrets into subprocess environments without ambient shell exposure. Secrets never enter your parent shell, never appear in `env`/`printenv`, and are encrypted at rest with keychain-backed passphrases.

## Install

```bash
# All users (/usr/local/bin, may prompt for sudo)
curl -fsSL https://raw.githubusercontent.com/ags-slc/s2/main/install.sh | sh

# Single user (~/.local/bin)
curl -fsSL https://raw.githubusercontent.com/ags-slc/s2/main/install.sh | sh -s -- --user
```

Or with Rust:

```bash
cargo install s2-simple-secrets   # from crates.io
cargo install --path .      # from source
```

**Windows:** use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and follow the Linux instructions.

### Shell Completions

```bash
# Bash
s2 completions bash > ~/.bash_completion.d/s2

# Zsh
s2 completions zsh > ~/.zfunc/_s2

# Fish
s2 completions fish > ~/.config/fish/completions/s2.fish
```

## Quick Start

```bash
# Create a secret file (encrypted by default, passphrase stored in keychain)
s2 init ~/.secrets

# Add secrets (reads values from stdin, never from CLI args)
# set/unset transparently handle encrypted files
echo "AKIA..." | s2 set AWS_SECRET_ACCESS_KEY -f ~/.secrets

# Run any command with secrets injected
s2 exec -f ~/.secrets -- aws s3 ls
s2 exec -f ~/.secrets -- terraform apply
s2 exec -f ~/.secrets -- kubectl get pods

# List available keys (never shows values)
s2 list -f ~/.secrets

# Check if required keys exist
s2 check AWS_SECRET_ACCESS_KEY -f ~/.secrets

# Edit encrypted file (decrypts → $EDITOR → re-encrypts)
s2 edit ~/.secrets

# Filter sensitive output through redact
kubectl logs pod | s2 redact -f ~/.secrets
```

## Commands

| Command | Description |
|---------|-------------|
| `s2 exec` | Load secrets and exec a command with them in the environment |
| `s2 list` | List secret key names and source files (never values) |
| `s2 check` | Exit 0 if all specified keys exist, exit 1 with missing names |
| `s2 init` | Create a new secret file (encrypted by default, `--no-encrypt` for plaintext) |
| `s2 set` | Set a secret (reads value from stdin, handles encrypted files) |
| `s2 unset` | Remove a secret from a file (handles encrypted files) |
| `s2 encrypt` | Encrypt an existing plaintext file with age |
| `s2 decrypt` | Decrypt an age-encrypted file |
| `s2 edit` | Decrypt → $EDITOR → re-encrypt |
| `s2 redact` | Pipe filter replacing secret values with `[REDACTED]` |
| `s2 scan` | Scan files for secrets (regex patterns + entropy analysis) |
| `s2 completions` | Generate shell completion scripts (bash, zsh, fish, powershell) |

## Secret Scanning

Detect secrets in files before they're committed.

```bash
# Scan current directory
s2 scan .

# Scan specific files
s2 scan src/config.rs .env

# Pre-commit hook mode (only git staged files)
s2 scan --staged

# JSON output for CI
s2 scan . --json
```

Exit code 1 if secrets are found (blocks commits when used as a hook).

### List Rules

See all built-in and custom scan rules:

```bash
s2 scan --list-rules
```

### Custom Rules

Add company-specific patterns to `~/.config/s2/config.toml`:

```toml
[[scan.rules]]
id = "quickbooks-token"
description = "QuickBooks Online API Token"
pattern = 'qbo_[a-zA-Z0-9]{32}'

[[scan.rules]]
id = "vercel-token"
description = "Vercel API Token"
pattern = '[a-zA-Z0-9]{24}'
keyword = "VERCEL"    # only flag if "VERCEL" appears on the same line
```

### Pattern Learning

Test scan coverage against a file of known secrets and auto-generate rules for anything missed:

```bash
s2 scan --learn known-secrets.txt
```

Output shows which secrets are covered, suggests regex rules for gaps, and optionally appends them to your config.

### Allowlist

When a finding is a false positive, allow it by hash so it doesn't block future scans:

```bash
# Scan shows a hash for each finding
s2 scan --staged
#   .env:5  DB_PASSWORD  high-entropy  xK9m████████  97eb0519eeb61ed6

# Allow a specific finding
s2 scan --allow 97eb0519eeb61ed6

# Future scans skip that finding
s2 scan --staged
#   No secrets found (1 files scanned, 1 allowed)
```

Hashes are stored in `.s2allowlist` in the current directory. Commit it to share with your team, or gitignore it for personal use. Prefix matching is supported (minimum 8 characters).

### Pre-commit Hook

**Global (all repos):**

```bash
# Create global hooks directory
mkdir -p ~/.config/git/hooks

# Create the hook (chains to repo-local hooks if they exist)
cat > ~/.config/git/hooks/pre-commit << 'EOF'
#!/bin/sh
s2 scan --staged || exit 1

# Chain to repo-local hook if it exists
if [ -x .git/hooks/pre-commit ]; then
    exec .git/hooks/pre-commit
fi
EOF
chmod +x ~/.config/git/hooks/pre-commit

# Tell git to use it
git config --global core.hooksPath ~/.config/git/hooks
```

**Per-repo:**

```bash
echo '#!/bin/sh
exec s2 scan --staged' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```

### What it Detects

**High confidence** (pattern matching): AWS access keys, GitHub tokens, Stripe keys, Slack tokens/webhooks, PEM private keys, JWTs, Google API keys, Twilio/SendGrid keys, Shopify tokens, GitLab PATs, DigitalOcean tokens, Anthropic/OpenAI API keys, npm/PyPI tokens, Supabase keys, Datadog keys, Heroku keys, Azure secrets.

**Medium confidence** (entropy analysis): Strings with high Shannon entropy (>4.5) and length >20 characters. Catches secrets without a known pattern format. Placeholder values (`changeme`, `REPLACE_ME`, `your-*-here`, etc.) are automatically excluded to reduce false positives.

## Biometric Authentication (Touch ID)

Require Touch ID on macOS before any secret is decrypted or injected:

```toml
# ~/.config/s2/config.toml
biometric = true
```

When enabled, `s2 exec`, `s2 edit`, `s2 decrypt`, and `s2 set` trigger a Touch ID prompt. Commands that don't access secrets (`s2 scan`, `s2 hook`, `s2 list` on plaintext files) don't prompt.

Existing keychain items auto-migrate to biometric protection on next access. On Linux or headless systems, the option is ignored.

## Configuration

Create `~/.config/s2/config.toml`:

```toml
default_files = ["~/.secrets"]
biometric = true   # optional: require Touch ID on macOS
audit_log = "~/.config/s2/audit.log"

[profiles.aws]
files = ["~/.secrets"]
keys = ["AWS_SECRET_ACCESS_KEY"]

[profiles.deploy]
files = ["~/.secrets", ".env.local"]

# Per-profile provider overrides (replaces global [providers] for this profile)
[profiles.prod.providers.ssm]
profile = "prod-account"
region = "us-east-1"
```

Then use profiles:

```bash
s2 exec -p aws -- terraform apply
```

## CI/CD

s2 works in CI pipelines — secrets are scoped to individual steps, never leaked to the runner environment.

### GitHub Actions

```yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install s2
        run: curl -fsSL https://raw.githubusercontent.com/ags-slc/s2/main/install.sh | sh

      - name: Deploy
        run: |
          # Bridge CI secrets into s2, then inject into deploy script
          echo "${{ secrets.AWS_SECRET_ACCESS_KEY }}" | s2 set AWS_SECRET_ACCESS_KEY -f .secrets --no-encrypt
          s2 exec -f .secrets --clean-env -- ./deploy.sh
        # --clean-env ensures only secrets + minimal vars reach the subprocess

      - name: Scrub logs
        run: kubectl logs deploy/app | s2 redact -f .secrets
```

### SSM in CI

If your CI runner has AWS access, s2 can pull secrets directly from SSM — no pipeline variables needed:

```bash
# .secrets — individual parameters
DB_PASSWORD=ssm:///prod/apps/myapp/secrets/DB_PASSWORD
API_KEY=ssm:///prod/apps/myapp/secrets/API_KEY

# Or import all parameters under a prefix at once
*=ssm:///prod/apps/myapp/secrets/
```

The `*=ssm:///prefix/` syntax auto-discovers all parameters under the path and injects them as environment variables. Parameter names are converted: `/prod/apps/myapp/secrets/db_password` becomes `DB_PASSWORD` (prefix stripped, `/`/`-`/`.` replaced with `_`, uppercased).

By default, nested paths are included (recursive). Use `#shallow` to fetch only immediate children: `*=ssm:///prefix/#shallow`.

```yaml
- name: Run migrations
  run: s2 exec -f .secrets --clean-env -- ./run-migrations.sh
```

### Why s2 in CI?

- **Scoped injection** — secrets only reach the subprocess, not the entire runner environment
- **`--clean-env`** — strips runner env pollution, subprocess gets only what it needs
- **`s2 redact`** — pipe build or deploy logs through s2 to scrub any leaked values
- **`execve` replacement** — secrets don't persist in `/proc/pid/environ` after the step

## AI Agent Integration

s2 includes hooks that automatically inject secrets when AI agents run commands. The agent runs `aws s3 ls` and s2 transparently wraps it with `s2 exec`. Supports Claude Code, GitHub Copilot, Cursor, Codex, and OpenCode.

### Shared Config

Add a `[hook]` section to `~/.config/s2/config.toml` (used by all agents):

```toml
[hook]
commands = ["aws", "terraform", "kubectl", "docker"]
profile = "aws"       # or use: files = ["~/.secrets"]
```

### Guard (Secret Exposure Protection)

The hook includes a guard that blocks commands which would expose secrets to the agent. Enabled by default when hook files are configured.

**What it blocks:**
- **Env dumps**: bare `env`, `printenv` (allows `env VAR=val cmd`, `env -i cmd`, and `printenv HOME`)
- **File readers**: `cat`, `head`, `tail`, `less`, `more`, `bat`, `base64`, `xxd`, `strings`, `tee`, `vim`, `nano`, etc. targeting a configured secret file
- **File operations**: `cp`, `mv`, `scp`, `rsync`, `curl`, `wget` targeting a configured secret file
- **Search tools**: `grep`, `rg`, `ag`, `ack`, `sed`, `awk` targeting a configured secret file
- **Script interpreters**: `python`, `ruby`, `perl`, `node`, `source`, `.` targeting a configured secret file
- **Redirects & @-refs**: `< ~/.secrets`, `curl -d @~/.secrets`

**Configuration:**

```toml
[hook.guard]
enabled = true                              # default: true
env_deny = ["env", "printenv"]              # default
extra_files = [".env.local", "~/.aws/credentials"]  # guard additional files
# file_deny uses a sensible default (cat, head, grep, base64, cp, curl, etc.)
```

The guard resolves secret file paths from the same source as `s2 exec` (profile, files, or default_files) plus any `extra_files`.

### Claude Code / GitHub Copilot

Add to `~/.claude/settings.json` (or `.github/copilot-instructions.md` hook config):

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "s2 hook"
          }
        ]
      }
    ]
  }
}
```

For Copilot, use `s2 hook --format copilot` (same behavior, explicit format).

### Cursor

Add to `~/.cursor/hooks.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "s2 hook --format cursor"
          }
        ]
      }
    ]
  }
}
```

Or use the provided shell wrapper: `hooks/cursor/s2-rewrite.sh`.

### Codex

Copy `hooks/codex/s2-awareness.md` into your project's `AGENTS.md` or Codex instructions. Codex doesn't support programmatic hooks — the awareness file instructs the agent to use `s2 exec` directly.

### OpenCode

Copy `hooks/opencode/s2.ts` to `~/.config/opencode/plugins/s2.ts`. The plugin intercepts shell commands and pipes them through `s2 hook --format cursor`.

## Security Model

### How is this different from `source ~/.secrets`?

**With `source ~/.secrets` (current):**
- `env` / `printenv` shows all secrets in cleartext
- `echo $SECRET` prints the value
- `cat ~/.secrets` reads the plaintext file
- Every subprocess inherits all secrets automatically
- AI coding agents see secrets via process environment

**With `s2 exec`:**
- `env` / `printenv` shows nothing (parent shell has no secrets)
- `echo $SECRET` prints empty
- `cat ~/.secrets` shows ciphertext (encrypted at rest)
- `s2 list` shows key names only, never values
- Only the exec'd subprocess receives secrets

### Security layers

| Threat | Mitigation |
|--------|-----------|
| Unattended terminal access | Touch ID required before any secret access (`biometric = true`) |
| `env`/`printenv` in parent shell | Secrets never loaded into parent — only in exec'd subprocess |
| stdout/stderr | CLI never prints values; `SecretString` redacts Debug/Display |
| Shell history | `set` reads from stdin, not args; `exec` args are the target command |
| Files on disk | Encrypted at rest with keychain-backed passphrase (file-based fallback on headless Linux); 0600 enforced |
| AI agent reads secret files | Hook guard blocks `cat`, `grep`, `base64`, etc. on secret files |
| AI agent dumps environment | Hook guard blocks bare `env`/`printenv` |
| `/proc/<pid>/environ` | `execvp` replaces process; `--clean-env` reduces surface |
| Memory after use | `secrecy`/`zeroize` clear memory on drop |
| Process listing (`ps`) | Values never in argv |

### Key design decisions

- **`execve` replaces the process**: After exec, only the child command remains. Secrets never persist in a parent process. Signals forward correctly since the child IS the process.
- **Transparent encryption**: When loading files, s2 auto-detects age-encrypted content and decrypts in-memory using the keychain passphrase. No manual decrypt step needed.
- **0600 enforcement**: s2 refuses to read any file that is group or world readable.
- **stdin-only for values**: `s2 set` reads from stdin, never from CLI arguments, preventing shell history exposure.

## File Format

Supports both formats, auto-detected per line:

```bash
# Shell-sourceable format
export API_KEY=my-key
export DB_PASSWORD="has spaces"

# Dotenv format
API_KEY=my-key
DB_PASSWORD="has spaces"

# Single-quoted (literal, no escapes)
PATTERN='foo$bar'

# Comments and blank lines are skipped
```

## Similar Tools

| Tool | Description | Differentiator |
|------|-------------|----------------|
| [Teller]https://github.com/tellerops/teller | Multi-provider secret injection via `teller run`. CNCF project, now archived. | Aggregates external backends; no local encrypted store. |
| [SOPS]https://github.com/getsops/sops | Encrypts structured files at rest (age/PGP/KMS). Has `sops exec-env`. | File-format-centric (YAML/JSON field-level encryption), not a dedicated exec wrapper. |
| [Chamber]https://github.com/segmentio/chamber | `chamber exec svc -- cmd` injects secrets from AWS SSM Parameter Store. | AWS-only backend. |
| [envconsul]https://github.com/hashicorp/envconsul | Subprocess injection from Consul KV and Vault. | Locked to HashiCorp ecosystem. |
| [envchain]https://github.com/sorah/envchain | Stores secrets in system keychain, injects via `envchain ns cmd`. | No encrypted files, no config profiles, no redact. |
| [Infisical]https://github.com/Infisical/infisical | Full platform (server + dashboard + CLI) with `infisical run`. | Heavy; requires running a server. |
| [dotenvx]https://github.com/dotenvx/dotenvx | Encrypts `.env` files with ECIES. `dotenvx run -- cmd`. | Focused on `.env` format, not subprocess security model. |
| [1Password CLI `op run`]https://developer.1password.com/docs/cli/reference/commands/run/ | Resolves `op://` references, injects into subprocess. | Proprietary, single-backend (1Password). |
| [Doppler CLI]https://github.com/DopplerHQ/cli | `doppler run -- cmd` injects from Doppler cloud. | SaaS-only, not local-first. |

**Where s2 differs**: local-first encrypted store + `execve` process replacement (not child spawn) + `secrecy`/`zeroize` memory safety + 0600 enforcement + stream redaction. Most alternatives are either locked to a single backend, require a running server, or don't address the full threat surface (memory, process listing, parent shell leakage).