v0.1.1.0 feat: optional remote URL in .secrets-store (EGB-282) (#2)

* chore: ignore .gstack/ (per-project local state)

* feat: optional remote URL in .secrets-store (EGB-282)

A second whitespace-separated token after the store name in .secrets-store
is treated as the store's git remote URL. When a teammate clones a project
bound to a store they don't have locally yet, the directed missing-store
error now fills in `git clone <url> <path>` so they can copy-paste instead
of asking the original setter for the URL.

Backward compatible: single-token .secrets-store files (the v0.1.0.x
format) continue to work and produce the existing `<their-store-remote>`
placeholder.

Security hardening (caught by adversarial review during /ship):
- The rendered git clone line is meant to be copy-pasted by a teammate.
  Without sanitization, `work evil.git;rm -rf ~` would render verbatim
  and execute `rm -rf ~` on paste. The parser now rejects URLs containing
  shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl.
  ANSI escape sequences that could spoof terminal output), and embedded
  whitespace. Rejected URLs are dropped with a stderr warning; the error
  falls back to the safe placeholder.
- Switched from `set -- $line` to `read -r spec rest` so the URL field
  isn't glob-expanded or word-split — important so `work *` from a
  populated directory doesn't leak filenames into the URL field.

Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms,
comment-and-URL form, four named injection vectors (shell metachar,
backtick, $(), ANSI escape), multi-token URL, glob char, and a positive
test asserting standard git URL chars round-trip unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.1.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-05-09 14:54:45 -07:00 committed by GitHub
parent 1bb729f73b
commit 7c3a76e8c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 296 additions and 20 deletions

100
secrets
View file

@ -24,6 +24,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STORE_SOURCE="default"
# Path to the .secrets-store file that won resolution, if any.
_LAST_FOUND_AT=""
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
# in the missing-store error so teammates don't have to ask.
_REMOTE_URL=""
# --store flag value, captured by the pre-pass.
STORE_OVERRIDE=""
@ -41,12 +45,21 @@ check_initialized() {
return
fi
if [ "$STORE_SOURCE" != "default" ]; then
# Fill in the remote URL when .secrets-store provided one, so the
# teammate can copy-paste the clone command without asking the original
# setter for the URL. Falls back to a placeholder otherwise.
local clone_url
if [ -n "${_REMOTE_URL:-}" ]; then
clone_url="$_REMOTE_URL"
else
clone_url="<their-store-remote>"
fi
die "Store not initialized: $SECRETS_DIR
Resolved from: $STORE_SOURCE
This path doesn't exist on this machine yet.
If you're joining a teammate's existing store:
git clone <their-store-remote> $SECRETS_DIR
git clone $clone_url $SECRETS_DIR
# then copy their key.txt to $SECRETS_DIR/key.txt
If you want a fresh new store at this path:
@ -100,10 +113,20 @@ derive_project_name() {
# 4. $HOME/.secrets default
#
# .secrets-store file format: first non-empty non-comment line is the store
# spec. Spec is one of:
# spec, optionally followed by a remote URL on the same line. Spec is one of:
# - absolute path (/Users/you/.secrets-work)
# - ~/path (expanded to $HOME/path)
# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
#
# The optional second token (whitespace-separated) is a git remote URL. It
# is only used as a hint when the store directory does not yet exist on
# the current machine — the missing-store error includes a runnable
# `git clone <url> <path>` for the teammate to copy. Examples:
#
# work
# work git@github.com:acme/work-secrets.git
# ~/.secrets-work https://github.com/acme/work-secrets.git
#
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
# literal characters to prevent code injection from a committed file.
@ -136,8 +159,21 @@ _expand_store_path() {
esac
}
# Read a .secrets-store file. Print the first non-empty non-comment line,
# trimmed. Return 1 if no usable line is found.
# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
# (URL empty if not provided or rejected as unsafe). The first non-empty
# non-comment line is the active line; the first whitespace splits it into
# the spec and an optional URL.
#
# Security: the URL is later substituted into a copy-paste-ready `git clone`
# command in the missing-store error. An attacker who slips a malicious line
# into a committed .secrets-store could weaponize that copy-paste — e.g.
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
# and a teammate following the directed error would execute the payload.
# We reject URLs containing shell metacharacters, control characters, ANSI
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
# the parser's perspective (a warning is printed to stderr); the resolver
# falls back to the `<their-store-remote>` placeholder so the directed
# error stays useful without rendering the attacker-controlled string.
_parse_secrets_store_file() {
local file="$1"
local line
@ -150,7 +186,25 @@ _parse_secrets_store_file() {
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in '#'*) continue ;; esac
printf '%s\n' "$line"
# Split into spec (first whitespace-separated token) and url (rest of
# the line, verbatim). `read -r` does NOT glob-expand and preserves the
# tail in $rest as a single string — important so `work *` doesn't
# silently expand to `work file1 file2 ...`.
local spec="" rest=""
read -r spec rest <<< "$line"
local url="$rest"
if [ -n "$url" ]; then
# Reject URLs containing characters that could weaponize a
# copy-paste shell command, terminal escapes, or be ambiguous.
# Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
case "$url" in
*[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*)
echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
url=""
;;
esac
fi
printf '%s\t%s\n' "$spec" "$url"
return 0
done < "$file"
return 1
@ -159,8 +213,9 @@ _parse_secrets_store_file() {
# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp),
# the walk does not run. Symlinks are resolved with `cd -P`.
# On success: prints "<expanded-store-dir>\t<source-file-path>" and returns 0.
# The caller (resolve_store) splits the tab-separated tuple. We can't set a
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
# parent-shell variable from here because we're typically called inside
# `$(...)` command substitution, which runs in a subshell.
_find_secrets_store_file() {
@ -182,11 +237,14 @@ _find_secrets_store_file() {
if [ -L "$dir/.secrets-store" ]; then
:
elif [ -f "$dir/.secrets-store" ]; then
local content
if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then
local parsed
if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
# parsed is "<spec>\t<url>" (URL may be empty)
local spec="${parsed%%$'\t'*}"
local url="${parsed#*$'\t'}"
local expanded
expanded=$(_expand_store_path "$content")
printf '%s\t%s\n' "$expanded" "$dir/.secrets-store"
expanded=$(_expand_store_path "$spec")
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
return 0
fi
fi
@ -198,18 +256,25 @@ _find_secrets_store_file() {
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
# Sets STORE_SOURCE to one of:
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
# copy-paste-ready `git clone` command for teammates whose store doesn't
# exist yet.
resolve_store() {
local resolved=""
local source=""
_REMOTE_URL=""
if [ -n "${STORE_OVERRIDE:-}" ]; then
resolved=$(_expand_store_path "$STORE_OVERRIDE")
source="--store flag"
_LAST_FOUND_AT=""
elif _find_result=$(_find_secrets_store_file); then
# _find_secrets_store_file returns "<dir>\t<source-file-path>"
resolved="${_find_result%%$'\t'*}"
_LAST_FOUND_AT="${_find_result#*$'\t'}"
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
# save/restore needed. URL field is empty when .secrets-store didn't
# include a URL or when it was rejected as unsafe.
IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
source=".secrets-store file ($_LAST_FOUND_AT)"
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
resolved="$_USER_SECRETS_DIR"
@ -868,6 +933,13 @@ Stores:
Bare names ("work") expand to ~/.secrets-work. The name "default"
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
Optional .secrets-store URL hint:
A second whitespace-separated token on the line is treated as the
store's git remote URL. It is used to fill in a runnable `git clone
<url> <path>` in the missing-store error so teammates joining the
project don't have to ask for the URL. Example:
work git@github.com:acme/work-secrets.git
Workspaces:
With -w/--workspaces, reads package.json "workspaces" field to find
workspace directories. Each workspace's secret files are stored under