mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
security: harden migration + context-save after adversarial review
Adversarial review (Claude + Codex, both high confidence) identified 6
critical production-harm findings in the /ship pre-landing pass.
All folded in.
Migration v1.0.1.0.sh hardening:
- Add explicit `[ -z "${HOME:-}" ]` guard. HOME="" survives set -u and
expands paths to /.claude/skills/... which could hit absolute paths
under root/containers/sudo-without-H.
- Add python3 fallback inside resolve_real() (was missing; broken
symlinks silently defeated ownership check).
- Ownership-guard Shape 2 (~/.claude/skills/gstack/checkpoint/). Was
unconditional rm -rf. Now: if symlink, check target resolves inside
gstack; if regular dir, check realpath resolves inside gstack. A
user's hand-edited customization or a symlink pointing outside gstack
is preserved with a notice.
- Use `rm --` and `rm -r --` consistently to resist hostile basenames.
- Use `find -type f -not -name .DS_Store -not -name ._*` instead of
`ls -A | grep`. macOS sidecars no longer mask a legit prefix-mode
install. Strip sidecars explicitly before removing the dir.
context-save/SKILL.md.tmpl:
- Sanitize title in bash, not LLM prose. Allowlist [a-z0-9.-], cap 60
chars, default to "untitled". Closes a prompt-injection surface where
`/context-save $(rm -rf ~)` could propagate into subsequent commands.
- Collision-safe filename. If ${TIMESTAMP}-${SLUG}.md already exists
(same-second double-save with same title), append a 4-char random
suffix. The skill contract says "saved files are append-only" — this
enforces it. Silent overwrite was a data-loss bug.
context-restore/SKILL.md.tmpl:
- Cap `find ... | sort -r` at 20 entries via `| head -20`. A user with
10k+ saved files no longer blows the context window just to pick one.
/context-save list still handles the full-history listing path.
test/skill-e2e-autoplan-dual-voice.test.ts:
- Filter transcript to tool_use / tool_result / assistant entries
before matching, so prompt-text mentions of "plan-ceo-review" don't
force the reachedPhase1 assertion to pass. Phase-1 assertion now
requires completion markers ("Phase 1 complete", "Phase 2 started"),
not mere name occurrence.
- claudeVoiceFired now requires JSON evidence of an Agent tool_use
(name:"Agent" or subagent_type field), not the literal string
"Agent(" which could appear anywhere.
- codexVoiceFired now requires a Bash tool_use with a `codex exec/review`
command string, not prompt-text mentions.
All SKILL.md files regenerated. Golden fixtures updated. bun test: 0
failures across 80+ targeted tests and the full suite.
Review source: /ship Step 11 adversarial pass (claude subagent + codex
exec). Same findings independently surfaced by both reviewers — this is
cross-model high confidence.
This commit is contained in:
+21
-3
@@ -805,21 +805,39 @@ saved file.
|
||||
|
||||
### Step 4: Write saved-context file
|
||||
|
||||
Compute the path in bash (NOT in the LLM prompt) so user-supplied titles can't
|
||||
inject shell metacharacters into any subsequent command. The sanitizer is an
|
||||
allowlist: only `a-z 0-9 - .` survive.
|
||||
|
||||
```bash
|
||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG
|
||||
CHECKPOINT_DIR="$HOME/.gstack/projects/$SLUG/checkpoints"
|
||||
mkdir -p "$CHECKPOINT_DIR"
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
# Bash-side title sanitize. Pass the raw title as $1 when running this block.
|
||||
# Example: TITLE_RAW="wintermute progress" bash -c '...'
|
||||
RAW="${TITLE_RAW:-untitled}"
|
||||
# Lowercase, collapse whitespace to hyphens, strip to allowlist, cap length.
|
||||
TITLE_SLUG=$(printf '%s' "$RAW" | tr '[:upper:]' '[:lower:]' | tr -s ' \t' '-' | tr -cd 'a-z0-9.-' | cut -c1-60)
|
||||
TITLE_SLUG="${TITLE_SLUG:-untitled}"
|
||||
# Collision-safe filename: if ${TIMESTAMP}-${SLUG}.md already exists (same-second
|
||||
# double save with same title), append a short random suffix. Filenames are
|
||||
# append-only — never overwrite.
|
||||
FILE="${CHECKPOINT_DIR}/${TIMESTAMP}-${TITLE_SLUG}.md"
|
||||
if [ -e "$FILE" ]; then
|
||||
SUFFIX=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c 4 || printf '%04x' "$$")
|
||||
FILE="${CHECKPOINT_DIR}/${TIMESTAMP}-${TITLE_SLUG}-${SUFFIX}.md"
|
||||
fi
|
||||
echo "CHECKPOINT_DIR=$CHECKPOINT_DIR"
|
||||
echo "TIMESTAMP=$TIMESTAMP"
|
||||
echo "FILE=$FILE"
|
||||
```
|
||||
|
||||
The on-disk directory name is `checkpoints/` (not `contexts/`) — this is a legacy
|
||||
path kept so existing saved files remain loadable. Users never see it.
|
||||
|
||||
Write the file to `{CHECKPOINT_DIR}/{TIMESTAMP}-{title-slug}.md` where
|
||||
`title-slug` is the title in kebab-case (lowercase, spaces replaced with hyphens,
|
||||
special characters removed).
|
||||
Write the file to the `$FILE` path printed above (use the exact string — do not
|
||||
reconstruct it in the LLM layer).
|
||||
|
||||
The file format:
|
||||
|
||||
|
||||
@@ -112,21 +112,39 @@ saved file.
|
||||
|
||||
### Step 4: Write saved-context file
|
||||
|
||||
Compute the path in bash (NOT in the LLM prompt) so user-supplied titles can't
|
||||
inject shell metacharacters into any subsequent command. The sanitizer is an
|
||||
allowlist: only `a-z 0-9 - .` survive.
|
||||
|
||||
```bash
|
||||
{{SLUG_SETUP}}
|
||||
CHECKPOINT_DIR="$HOME/.gstack/projects/$SLUG/checkpoints"
|
||||
mkdir -p "$CHECKPOINT_DIR"
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
# Bash-side title sanitize. Pass the raw title as $1 when running this block.
|
||||
# Example: TITLE_RAW="wintermute progress" bash -c '...'
|
||||
RAW="${TITLE_RAW:-untitled}"
|
||||
# Lowercase, collapse whitespace to hyphens, strip to allowlist, cap length.
|
||||
TITLE_SLUG=$(printf '%s' "$RAW" | tr '[:upper:]' '[:lower:]' | tr -s ' \t' '-' | tr -cd 'a-z0-9.-' | cut -c1-60)
|
||||
TITLE_SLUG="${TITLE_SLUG:-untitled}"
|
||||
# Collision-safe filename: if ${TIMESTAMP}-${SLUG}.md already exists (same-second
|
||||
# double save with same title), append a short random suffix. Filenames are
|
||||
# append-only — never overwrite.
|
||||
FILE="${CHECKPOINT_DIR}/${TIMESTAMP}-${TITLE_SLUG}.md"
|
||||
if [ -e "$FILE" ]; then
|
||||
SUFFIX=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c 4 || printf '%04x' "$$")
|
||||
FILE="${CHECKPOINT_DIR}/${TIMESTAMP}-${TITLE_SLUG}-${SUFFIX}.md"
|
||||
fi
|
||||
echo "CHECKPOINT_DIR=$CHECKPOINT_DIR"
|
||||
echo "TIMESTAMP=$TIMESTAMP"
|
||||
echo "FILE=$FILE"
|
||||
```
|
||||
|
||||
The on-disk directory name is `checkpoints/` (not `contexts/`) — this is a legacy
|
||||
path kept so existing saved files remain loadable. Users never see it.
|
||||
|
||||
Write the file to `{CHECKPOINT_DIR}/{TIMESTAMP}-{title-slug}.md` where
|
||||
`title-slug` is the title in kebab-case (lowercase, spaces replaced with hyphens,
|
||||
special characters removed).
|
||||
Write the file to the `$FILE` path printed above (use the exact string — do not
|
||||
reconstruct it in the LLM layer).
|
||||
|
||||
The file format:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user