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 7e136ab6e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 296 additions and 20 deletions

3
.gitignore vendored
View file

@ -4,3 +4,6 @@
# Editor
*.swp
*~
# Per-project gstack state (deploy reports, brain caches, etc.)
.gstack/

View file

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
## [0.1.1.0] - 2026-05-09
### Added
- **Optional git remote URL in `.secrets-store`.** Add a second whitespace-separated token after the store name to give teammates a copy-paste-ready clone command:
```
work git@github.com:acme/work-secrets.git
```
When a teammate clones a project bound to a store they don't have on their machine yet, the directed error now fills in the actual `git clone <url> <path>` line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token `.secrets-store` files continue to work and show the `<their-store-remote>` placeholder as before. (EGB-282)
### Security
- **Hardened `.secrets-store` URL parser against copy-paste shell injection.** The URL is rendered into a `git clone` line that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious `.secrets-store` containing `work evil.git;rm -rf ~` would render verbatim and execute `rm -rf ~` on paste. The parser now rejects URLs containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (including ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the directed error falls back to the safe placeholder. Found by adversarial review during /ship; verified with regression tests for every named attack vector.
- **Switched URL parsing from `set -- $line` to `read -r spec rest`.** The previous form word-split *and* glob-expanded — `work *` from a populated directory would have leaked filenames into the URL field. The new form preserves the rest of the line verbatim into a single variable, so glob characters and internal whitespace are noticed by the sanitizer instead of silently expanded.
### Tests
- 72 → 80 (+8). New coverage: backward-compat single-token form, two-token URL form (SSH, HTTPS, `~/`-prefixed), comment-and-URL form, copy-paste injection (rm -rf payload), backtick injection, `$()` injection, ANSI escape injection, multi-token URL, glob-character URL, and a positive test asserting standard git URL chars (`-`, `+`, `_`, `:`, `/`, `@`, `.`) round-trip unchanged.
## [0.1.0.1] - 2026-05-09
### Fixed
@ -42,5 +61,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
- 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1F5 adversarial regressions.
[0.1.1.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.1.0
[0.1.0.1]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.1
[0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0

View file

@ -56,7 +56,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig
`resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read.
`.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work``$HOME/.secrets-work`, `default``$HOME/.secrets`.
`.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work``$HOME/.secrets-work`, `default``$HOME/.secrets`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone <url> <path>` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `<their-store-remote>` placeholder. This matters because 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 the payload on paste. Internal flow: `_parse_secrets_store_file` returns `<spec>\t<url>`; `_find_secrets_store_file` returns `<dir>\t<source-path>\t<url>`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`.
## Deploy Configuration

View file

@ -267,6 +267,14 @@ git add .secrets-store
git commit -m "use work secrets store"
```
For teammates who haven't set up the store yet, you can include the store's git remote URL on the same line so they don't have to ask you for it:
```bash
echo "work git@github.com:acme/work-secrets.git" > .secrets-store
```
That second token (whitespace-separated) is optional, ignored when the store already exists locally, and used as a copy-paste-ready hint in the missing-store error message when it doesn't. See "Joining a teammate's bound project" below.
After that, every `secrets push` / `secrets pull` from this project (or any subdirectory) automatically uses `~/.secrets-work`. Teammates who clone the project get the same binding for free — the file is in the repo.
When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately:
@ -278,7 +286,9 @@ When you push or pull from a non-default store, `secrets` echoes which one is ac
#### Joining a teammate's bound project
If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do:
If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do.
When the original setter included the remote URL in `.secrets-store` (recommended), the error fills in the actual `git clone` command for you to copy-paste:
```
ERROR: Store not initialized: /Users/you/.secrets-work
@ -286,16 +296,16 @@ ERROR: Store not initialized: /Users/you/.secrets-work
This path doesn't exist on this machine yet.
If you're joining a teammate's existing store:
git clone <their-store-remote> /Users/you/.secrets-work
git clone git@github.com:acme/work-secrets.git /Users/you/.secrets-work
# then copy their key.txt to /Users/you/.secrets-work/key.txt
If you want a fresh new store at this path:
secrets --store /Users/you/.secrets-work init
```
You'll need two things from the teammate who set it up:
If the URL wasn't in the file, the error shows `<their-store-remote>` as a placeholder — you'll need to ask the teammate who set it up. Either way, you need two things to finish onboarding:
1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine).
1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine). The `.secrets-store` file may already include this for you.
2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB.
Once both are in place, `secrets pull` works.

View file

@ -1 +1 @@
0.1.0.1
0.1.1.0

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

View file

@ -874,3 +874,174 @@ PKG
[ "$status" -ne 0 ]
[[ "$output" == *"HOME"* ]]
}
# ─── EGB-282: optional remote URL in .secrets-store ──────────────────
@test "EGB-282: .secrets-store without URL still works (backward compat)" {
unset SECRETS_DIR
mkdir -p "$HOME/.secrets-work"
mkdir -p "$WORK_DIR/proj"
# Single token only — same as v0.1.0.0 format
echo "work" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"$HOME/.secrets-work"* ]]
}
@test "EGB-282: .secrets-store with URL parses both tokens" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "work git@github.com:acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
# Store doesn't exist yet — pull should fail with the directed error
# that includes the actual URL (not the placeholder).
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git $HOME/.secrets-work"* ]]
# Placeholder must NOT appear when a real URL was supplied
[[ "$output" != *"<their-store-remote>"* ]]
}
@test "EGB-282: missing-store error still works without URL (placeholder)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "missing-only" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
# No URL given — placeholder is the right behavior.
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282: https URL is preserved literally" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "work https://github.com/acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"https://github.com/acme/work-secrets.git"* ]]
}
@test "EGB-282: ~/-prefixed path with URL works" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf '~/.secrets-x git@github.com:acme/x.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/x.git $HOME/.secrets-x"* ]]
}
@test "EGB-282: comments before URL line are still skipped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf '# this binding was set by alice\n# please do not delete\nwork git@github.com:acme/work-secrets.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git"* ]]
}
# ─── EGB-282 adversarial regressions: URL injection prevention ────────
@test "EGB-282 SECURITY: URL with shell metachars is dropped (rm -rf payload)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# The classic copy-paste shell injection: a `;` after the "URL" splits
# the rendered git clone into two commands, the second of which is the
# attacker payload. The teammate copy-pasting the directed-error one-liner
# would execute `rm -rf ~`. The parser must reject this.
printf 'work evil.git;rm -rf ~\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
# Must use the placeholder, NOT the attacker URL
[[ "$output" == *"<their-store-remote>"* ]]
[[ "$output" != *"rm -rf"* ]]
# And must have warned the user that something was dropped
[[ "$output" == *"WARNING"* ]]
[[ "$output" == *"unsafe"* ]]
}
@test "EGB-282 SECURITY: URL with backticks is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf 'work evil.git`whoami`\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: URL with command substitution \$() is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf 'work evil.git$(whoami)\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: URL with ANSI escape is dropped (terminal-spoof prevention)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# ESC ([ \x1b) lets a malicious URL render differently from what gets pasted
printf 'work evil.git\x1b[2K\r\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: multi-token URL ('work url1 url2') is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# `git clone url1 url2 /path` would clone url1 into directory `url2` — wrong
# behavior either way. Treat any whitespace inside the URL token as unsafe.
printf 'work url1 url2\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: glob char in URL is dropped (no expansion either way)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# In v0.1.1.0-alpha (set -- $line), this used to expand to filenames.
# The fixed parser uses `read -r`, so it doesn't glob — but glob chars
# are still rejected as suspicious.
printf 'work /tmp/*\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282: spec parsing is glob-safe (work * does NOT expand)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# Create files in cwd that would expand if `set -- $line` were used.
touch "$WORK_DIR/proj/file1" "$WORK_DIR/proj/file2"
printf 'work *\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
# Spec is the literal "work" (resolves to ~/.secrets-work). The "*" gets
# rejected as unsafe URL and dropped. Resolution works; no globbing.
[[ "$output" == *"$HOME/.secrets-work"* ]]
}
@test "EGB-282: URL with - + _ : / @ . is preserved (positive test)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# Standard git URL chars must NOT be rejected
printf 'work git+ssh://user@host:2222/path/to-repo_v2.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
}