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
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ sp doctor # check store health (orphans, missing file
sp doctor --json | jq -e '.healthy' # gate a script on store health
sp ls --json | jq '.[].id' # machine-readable output for scripting
sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell
sp scan <id> # tripwire: does this scratch hold a secret?
sp resurrect <id> # changed your mind? pull it back
sp promote <id> # the good ones: graduate a scratch into your repo
```
Expand All @@ -34,10 +35,12 @@ sp promote <id> # the good ones: graduate a scratch into yo
`sp new` and `sp ls` are implemented (M3); the full lifecycle —
`sp cat`, `sp open`, `sp rm` (soft-delete), `sp resurrect`, and `sp ls --morgue`
(M4); **automatic reaping** — `sp reap`, with human-friendly TTLs and a
`--dry-run` preview (M5); and a read-only **`sp doctor`** store health check
plus scripting polish — **`sp ls --json`** / **`sp doctor --json`** and
**`sp completion`** for bash/zsh/fish, and **`sp promote`** to graduate a scratch
into your repo (M6, in progress).
`--dry-run` preview (M5); a read-only **`sp doctor`** store health check;
scripting polish — **`sp ls --json`** / **`sp doctor --json`** and
**`sp completion`** for bash/zsh/fish; **`sp promote`** to graduate a scratch
into your repo; and a **secret tripwire** — **`sp scan`** flags scratches that
look like they hold credentials, `sp ls` marks them with a 🔑, and `sp promote`
refuses them unless you pass `--allow-secrets` (M6, in progress).

### `sp new [name]`

Expand Down Expand Up @@ -128,6 +131,7 @@ sp promote 1a2b ./notes # into a directory: ./notes/<slug>.<ext>
sp promote 1a2b keep.md # to an explicit path (renames on the way out)
sp promote 1a2b keep.md --force # overwrite an existing destination
sp promote 1a2b --no-open # don't open it in $EDITOR afterwards
sp promote 1a2b --allow-secrets # promote even if the secret tripwire flags it
```

- With no `dest`, the file lands in the current directory under a slug of the
Expand All @@ -136,6 +140,8 @@ sp promote 1a2b --no-open # don't open it in $EDITOR afterwards
full target path.
- Promoting **never overwrites** an existing file without `--force`, and a
refused promote leaves the scratch untouched in the store.
- Promoting a scratch that trips the **secret tripwire** is refused unless you
pass `--allow-secrets` — run `sp scan <id>` to see the masked findings first.
- After moving, the promoted file opens in `$EDITOR` (skip with `--no-open`);
a missing `$EDITOR` is not fatal — the move already happened.

Expand Down Expand Up @@ -231,6 +237,47 @@ never null). Gate a script on the store's health without parsing prose:
`sp doctor --json | jq -e '.healthy'`, or list drift with
`sp doctor --json | jq '.orphans[].path'`.

### `sp scan <id>` — the secret tripwire

AI coding agents and tired humans leak API keys and `.env` dumps into throwaway
files without thinking. `sp scan` runs a conservative heuristic detector over a
single scratch and reports anything that looks like a credential — **with the
values masked**. It never echoes a full secret back to your terminal.

It catches:

- **AWS access key ids** (`AKIA…`/`ASIA…` + the fixed-length tail),
- **PEM private-key headers** (`-----BEGIN … PRIVATE KEY-----`),
- **secret-looking assignments** — `API_KEY=`, `TOKEN=`, `SECRET=`,
`PASSWORD=` and friends with a non-placeholder value,
- **long high-entropy tokens** that look generated (bearer tokens, opaque keys).

The heuristics are deliberately conservative to avoid alarm fatigue: template
values like `API_KEY=changeme`, `TOKEN=<your-token>`, and `${VAR}` references
stay quiet, as do ordinary prose, long numbers, and URLs.

```bash
sp scan 1a2b # report masked findings by line number
sp scan 1a2b --no-color # plain, script-friendly
sp scan 1a2b --json # stable JSON object (no color, no flavor)
sp scan 1a2b || echo blocked # non-zero exit when secrets are found
```

A clean scratch prints a one-line bill of health and exits `0`. A tripped one
lists each finding as `L<line> <rule> <masked>` and exits **non-zero**, so
`sp scan` slots straight into pre-commit hooks and CI. The `--json` form carries
a top-level `tripped` flag and a `findings` array (always an array, never null;
each finding has `kind`, `line`, `rule`, and a `masked` preview — never the raw
value): `sp scan 1a2b --json | jq -e '.tripped | not'`.

The tripwire also shows up where it matters most:

- **`sp ls`** puts a 🔑 next to any scratch that trips, and `sp ls --json` sets
`"secret": true` on it.
- **`sp promote`** refuses to graduate a tripped scratch into your repo unless
you pass `--allow-secrets` — the last line of defense before a leaked key
lands somewhere it might get committed.

### `sp completion <bash|zsh|fish>`

Prints a shell completion script to stdout so `sp`'s commands and flags
Expand Down
7 changes: 7 additions & 0 deletions cmd/sp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package main

import (
"errors"
"fmt"
"os"

Expand All @@ -11,6 +12,12 @@ import (

func main() {
if err := cli.NewRootCommand().Execute(); err != nil {
// `sp scan` signals "secrets found" with a message-less sentinel so it
// can gate hooks/CI via exit code without a redundant stderr line — the
// scan report already said everything. Exit non-zero, but stay quiet.
if errors.Is(err, cli.ErrSecretsFound) {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Expand Down
37 changes: 35 additions & 2 deletions internal/cli/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (

"github.com/spf13/cobra"

"github.com/rwrife/scratchpatch/internal/index"
"github.com/rwrife/scratchpatch/internal/render"
"github.com/rwrife/scratchpatch/internal/secret"
"github.com/rwrife/scratchpatch/internal/store"
)

Expand All @@ -26,6 +28,9 @@ func newLsCommand() *cobra.Command {
"text with no color codes.\n\n" +
"Pass --morgue to list soft-deleted scratches instead, showing how long\n" +
"until each is purged for good.\n\n" +
"A 🔑 next to a scratch's name means it tripped the secret tripwire — run\n" +
"`sp scan <id>` to see the masked findings. Such scratches can't be\n" +
"promoted into a repo without --allow-secrets.\n\n" +
"Pass --json for a stable, machine-readable array (no color, no flavor)\n" +
"suitable for scripting: `sp ls --json | jq '.[].id'`.",
Args: cobra.NoArgs,
Expand Down Expand Up @@ -74,10 +79,38 @@ func runLs(cmd *cobra.Command, noColor, morgue, asJSON bool) error {
return err
}

// Flag any live scratch that trips the secret tripwire so `sp ls` shows a
// 🔑 next to it (and --json carries "secret": true). Scanning is best-effort:
// a scratch whose content can't be read just goes unflagged rather than
// failing the whole listing.
markers := secretMarkers(st, scratches)

if asJSON {
return render.TableJSON(out, scratches, now)
return render.TableMarkedJSON(out, scratches, markers, now)
}
return render.TableMarked(out, scratches, markers, now, color)
}

// secretMarkers scans each scratch's content and returns the set of ids that
// tripped the secret tripwire, for `sp ls` to mark. It reads content directly
// and swallows per-scratch read errors: a listing should never fail because one
// file went missing, and doctor is the command that reports such drift. Returns
// nil when nothing tripped so the render layer can skip marking entirely.
func secretMarkers(st *store.Store, scratches []index.Scratch) map[string]bool {
var markers map[string]bool
for _, sc := range scratches {
content, err := st.ReadContent(sc)
if err != nil {
continue
}
if secret.Tripped(content) {
if markers == nil {
markers = make(map[string]bool)
}
markers[sc.ID] = true
}
}
return render.Table(out, scratches, now, color)
return markers
}

// isTerminal reports whether w is a character device (a TTY), which is our
Expand Down
54 changes: 51 additions & 3 deletions internal/cli/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
"github.com/spf13/cobra"

"github.com/rwrife/scratchpatch/internal/index"
"github.com/rwrife/scratchpatch/internal/secret"
"github.com/rwrife/scratchpatch/internal/store"
)

// promoteFlags holds the parsed `sp promote` options.
type promoteFlags struct {
force bool
noOpen bool
force bool
noOpen bool
allowSecrets bool
}

func newPromoteCommand() *cobra.Command {
Expand All @@ -32,7 +34,11 @@ func newPromoteCommand() *cobra.Command {
"If [dest] is an existing directory the file is placed inside it under a\n" +
"slug of its name; otherwise [dest] is the full target path. Promoting\n" +
"never overwrites an existing file unless --force is given. The id may be\n" +
"an unambiguous prefix.",
"an unambiguous prefix.\n\n" +
"Before moving, promote runs the secret tripwire over the scratch and\n" +
"refuses to graduate one that looks like it holds a credential (API keys,\n" +
"private keys, `TOKEN=`/`SECRET=` assignments). Run `sp scan <id>` to see\n" +
"the masked findings, or pass --allow-secrets to promote it anyway.",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
dest := ""
Expand All @@ -45,6 +51,7 @@ func newPromoteCommand() *cobra.Command {

cmd.Flags().BoolVar(&f.force, "force", false, "overwrite the destination if a file is already there")
cmd.Flags().BoolVar(&f.noOpen, "no-open", false, "don't open the promoted file in $EDITOR after moving")
cmd.Flags().BoolVar(&f.allowSecrets, "allow-secrets", false, "promote even if the scratch trips the secret tripwire")

return cmd
}
Expand All @@ -59,6 +66,19 @@ func runPromote(cmd *cobra.Command, ref, dest string, f promoteFlags) error {
return err
}

// Secret tripwire: refuse to graduate a scratch that looks like it holds a
// credential into the working tree, unless the user explicitly overrides.
// This is the last line of defense before a leaked key lands in a repo where
// it might get committed. Checked before any move so a blocked promote
// changes nothing.
if !f.allowSecrets {
if blocked, serr := promoteSecretGuard(st, sc); serr != nil {
return serr
} else if blocked != nil {
return blocked
}
}

target, err := promoteTarget(sc, dest)
if err != nil {
return err
Expand Down Expand Up @@ -158,3 +178,31 @@ func promoteError(err error, target string) error {
}
return err
}

// promoteSecretGuard runs the secret tripwire over the scratch's content and,
// if it trips, returns a blocking error explaining how to inspect (`sp scan`)
// or override (--allow-secrets). A nil error and nil block mean the scratch is
// clean (or its content couldn't be read as text, in which case we don't block
// on a read error — promote itself will surface any real content problem). The
// error deliberately names the finding count but not the secret values; use
// `sp scan` to see the masked details.
func promoteSecretGuard(st *store.Store, sc index.Scratch) (blocked error, err error) {
content, rerr := st.ReadContent(sc)
if rerr != nil {
// Don't turn a content-read problem into a secret block; let the actual
// promote path report it. Returning nil,nil means "not blocked here".
return nil, nil
}
findings := secret.Scan(content)
if len(findings) == 0 {
return nil, nil
}
n := "secret"
if len(findings) != 1 {
n = "secrets"
}
return fmt.Errorf(
"refusing to promote %s (%s): %d %s detected \u2014 run `sp scan %s` to see "+
"the (masked) findings, or pass --allow-secrets to promote anyway",
sc.ID, displayName(sc), len(findings), n, sc.ID), nil
}
Loading
Loading