mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a cwd bunfig.toml preload before the program starts. gstack runs inside the repo it audits, so a committed .env could steer state roots, the claude binary or spend caps, and a bunfig preload ran arbitrary code in every bin and hook. Every bun shebang, shell and skill caller, TypeScript spawn and compiled build now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(), --no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture regressions. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: name the directly executed scripts in the isolation ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: plan-mode render pattern accepts the isolation flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
251 lines
11 KiB
Bash
Executable File
251 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-team-init — generate repo-level bootstrap files for team mode
|
|
#
|
|
# Usage:
|
|
# gstack-team-init optional # gentle CLAUDE.md suggestion, one-time offer
|
|
# gstack-team-init required # CLAUDE.md enforcement + PreToolUse hook
|
|
#
|
|
# Run from the root of your team's repo (not from the gstack directory).
|
|
|
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
|
# pipe in the forked child before exec, with no reader on the other end. On
|
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
|
# bypass the shebang.
|
|
BASH_COMPAT=50
|
|
|
|
set -euo pipefail
|
|
|
|
MODE="${1:-}"
|
|
|
|
if [ "$MODE" != "optional" ] && [ "$MODE" != "required" ]; then
|
|
echo "Usage: gstack-team-init {optional|required}" >&2
|
|
echo "" >&2
|
|
echo " optional — suggest gstack install once per developer (gentle)" >&2
|
|
echo " required — enforce gstack install, block work without it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Must be in a git repo
|
|
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
echo "Error: not in a git repository. Run from your project root." >&2
|
|
exit 1
|
|
fi
|
|
|
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
CLAUDE_MD="$REPO_ROOT/CLAUDE.md"
|
|
GENERATED=()
|
|
|
|
# ── Migrate vendored copy if present ──────────────────────────
|
|
|
|
if [ -d "$REPO_ROOT/.claude/skills/gstack" ] && [ ! -L "$REPO_ROOT/.claude/skills/gstack" ]; then
|
|
if [ -f "$REPO_ROOT/.claude/skills/gstack/VERSION" ] || [ -d "$REPO_ROOT/.claude/skills/gstack/.git" ]; then
|
|
echo " Found vendored gstack copy at $REPO_ROOT/.claude/skills/gstack"
|
|
echo " Team mode uses the global install — removing vendored copy..."
|
|
( cd "$REPO_ROOT" && git rm -r --cached .claude/skills/gstack/ 2>/dev/null ) || true
|
|
if [ -f "$REPO_ROOT/.gitignore" ]; then
|
|
if ! grep -qF '.claude/skills/gstack/' "$REPO_ROOT/.gitignore" 2>/dev/null; then
|
|
echo '.claude/skills/gstack/' >> "$REPO_ROOT/.gitignore"
|
|
fi
|
|
else
|
|
echo '.claude/skills/gstack/' > "$REPO_ROOT/.gitignore"
|
|
fi
|
|
rm -rf "$REPO_ROOT/.claude/skills/gstack"
|
|
GENERATED+=(".gitignore")
|
|
echo " Removed vendored copy and added .claude/skills/gstack/ to .gitignore"
|
|
fi
|
|
fi
|
|
|
|
# ── CLAUDE.md snippet ──────────────────────────────────────────
|
|
|
|
if [ "$MODE" = "optional" ]; then
|
|
SNIPPET='## gstack (recommended)
|
|
|
|
This project uses [gstack](https://github.com/garrytan/gstack) for AI-assisted workflows.
|
|
Install it for the best experience:
|
|
|
|
```bash
|
|
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
|
cd ~/.claude/skills/gstack && ./setup --team
|
|
```
|
|
|
|
Skills like /qa, /ship, /review, /investigate, and /browse become available after install.
|
|
Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback). Use ~/.claude/skills/gstack/... for gstack file paths.'
|
|
else
|
|
SNIPPET='## gstack (REQUIRED — global install)
|
|
|
|
**Before doing ANY work, verify gstack is installed:**
|
|
|
|
```bash
|
|
_GS=""
|
|
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gstack/repos/gstack"; do
|
|
[ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D"
|
|
done
|
|
[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING"
|
|
```
|
|
|
|
If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
|
|
|
|
> gstack is required for all AI-assisted work in this repo.
|
|
> Install it:
|
|
> ```bash
|
|
> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
|
> cd ~/.claude/skills/gstack && ./setup --team
|
|
> ```
|
|
> Then restart your AI coding tool.
|
|
|
|
Do not skip skills, ignore gstack errors, or work around missing gstack.
|
|
|
|
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
|
|
and /browse are available. Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback).
|
|
Use the resolved install path above for gstack file paths
|
|
(default: ~/.claude/skills/gstack).'
|
|
fi
|
|
|
|
# Check if CLAUDE.md already has a gstack section
|
|
if [ -f "$CLAUDE_MD" ] && grep -q "## gstack" "$CLAUDE_MD" 2>/dev/null; then
|
|
echo "CLAUDE.md already has a gstack section. Skipping CLAUDE.md update."
|
|
echo " To replace it, remove the existing ## gstack section and re-run."
|
|
else
|
|
if [ -f "$CLAUDE_MD" ]; then
|
|
echo "" >> "$CLAUDE_MD"
|
|
fi
|
|
echo "$SNIPPET" >> "$CLAUDE_MD"
|
|
GENERATED+=("CLAUDE.md")
|
|
echo " + CLAUDE.md — added gstack $MODE section"
|
|
fi
|
|
|
|
# ── Required mode: enforcement hook ────────────────────────────
|
|
|
|
if [ "$MODE" = "required" ]; then
|
|
HOOKS_DIR="$REPO_ROOT/.claude/hooks"
|
|
SETTINGS="$REPO_ROOT/.claude/settings.json"
|
|
|
|
# Create the enforcement hook. Claude Code and Copilot CLI may run hook
|
|
# commands through sh, cmd.exe or PowerShell, so the hook is CommonJS run by
|
|
# node, and the registered command holds no shell variable syntax: it reads
|
|
# CLAUDE_PROJECT_DIR through process.env. `.cjs` keeps a project's
|
|
# package.json "type" from changing how node loads it.
|
|
mkdir -p "$HOOKS_DIR"
|
|
cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF'
|
|
'use strict';
|
|
// Block skill usage when gstack is not installed globally.
|
|
//
|
|
// Resolve the install root the way gstack skill preambles do: the GSTACK_ROOT
|
|
// env var first, then every host's global install location, then the migrated
|
|
// repo location. Block only when NONE exist (#2500 — hardcoding
|
|
// ~/.claude/skills/gstack false-blocked Codex-host and migrated-repo installs).
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
const roots = [process.env.GSTACK_ROOT || ''].concat([
|
|
'.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack',
|
|
'.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack',
|
|
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gstack/repos/gstack',
|
|
].map((rel) => path.join(home, rel)));
|
|
const gstackRoot = roots.find((root) => {
|
|
try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; }
|
|
});
|
|
|
|
if (gstackRoot) {
|
|
process.stdout.write('{}\n');
|
|
} else {
|
|
process.stderr.write(`BLOCKED: gstack is not installed globally.
|
|
|
|
gstack is required for AI-assisted work in this repo.
|
|
|
|
Install it:
|
|
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
|
cd ~/.claude/skills/gstack && ./setup --team
|
|
|
|
Then restart your AI coding tool.
|
|
`);
|
|
// Exit 2 denies on Claude Code (#2413) and on Copilot CLI, which treats a
|
|
// preToolUse exit 2 as deny (#2229).
|
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
permissionDecisionReason: 'gstack is required but not installed. See stderr for install instructions.' } }) + '\n');
|
|
process.exitCode = 2;
|
|
}
|
|
HOOK_EOF
|
|
chmod +x "$HOOKS_DIR/check-gstack.cjs"
|
|
GENERATED+=(".claude/hooks/check-gstack.cjs")
|
|
echo " + .claude/hooks/check-gstack.cjs — enforcement hook"
|
|
|
|
# Add hook to project-level settings.json
|
|
HOOK_COMMAND="node -e \"require(require('path').resolve(process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))\""
|
|
if ! command -v bun >/dev/null 2>&1; then
|
|
echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill) to .claude/settings.json:"
|
|
echo " $HOOK_COMMAND"
|
|
elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config=/dev/null -e "
|
|
const fs = require('fs');
|
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
|
const command = process.env.GSTACK_HOOK_COMMAND;
|
|
|
|
let settings = {};
|
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
|
|
if (!settings.hooks) settings.hooks = {};
|
|
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
|
|
// Claude Code's skill tool is Skill; Copilot CLI's is skill, which no
|
|
// Claude tool name maps to, so a bare 'Skill' never fired there (#2229).
|
|
// A registration an older team-init wrote (the .sh hook, matcher Skill)
|
|
// is replaced in place: exactly one check-gstack handler remains, and
|
|
// unrelated hooks in the same entry are kept.
|
|
const MATCHER = 'Skill|skill';
|
|
const isOurs = h => h && typeof h.command === 'string' && h.command.includes('check-gstack');
|
|
let placed = false;
|
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
|
|
if (entry.matcher !== 'Skill' && entry.matcher !== MATCHER) return true;
|
|
if (!Array.isArray(entry.hooks) || !entry.hooks.some(isOurs)) return true;
|
|
entry.matcher = MATCHER;
|
|
const kept = [];
|
|
for (const h of entry.hooks) {
|
|
if (!isOurs(h)) kept.push(h);
|
|
else if (!placed) { kept.push({ type: 'command', command }); placed = true; }
|
|
}
|
|
entry.hooks = kept;
|
|
return kept.length > 0;
|
|
});
|
|
if (!placed) settings.hooks.PreToolUse.push({ matcher: MATCHER, hooks: [{ type: 'command', command }] });
|
|
|
|
const tmp = settingsPath + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
fs.renameSync(tmp, settingsPath);
|
|
"; then
|
|
GENERATED+=(".claude/settings.json")
|
|
echo " + .claude/settings.json — PreToolUse hook registered"
|
|
# The bash hook an older team-init wrote goes only once its replacement is
|
|
# registered; a tracked one is staged as a deletion by the git add below.
|
|
if [ -e "$HOOKS_DIR/check-gstack.sh" ]; then
|
|
rm -f "$HOOKS_DIR/check-gstack.sh"
|
|
git -C "$REPO_ROOT" ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1 \
|
|
&& GENERATED+=(".claude/hooks/check-gstack.sh")
|
|
echo " - .claude/hooks/check-gstack.sh — replaced by check-gstack.cjs"
|
|
fi
|
|
else
|
|
echo " ! could not update .claude/settings.json — add this PreToolUse hook (matcher Skill|skill) manually:"
|
|
echo " $HOOK_COMMAND"
|
|
fi
|
|
fi
|
|
|
|
# ── Summary ────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo "Team mode ($MODE) initialized."
|
|
echo ""
|
|
if [ ${#GENERATED[@]} -gt 0 ]; then
|
|
echo "Commit the generated files:"
|
|
echo " git add ${GENERATED[*]}"
|
|
echo " git commit -m \"chore: require gstack for AI-assisted work\""
|
|
fi
|
|
echo ""
|
|
echo "Each developer then runs:"
|
|
echo " git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack"
|
|
echo " cd ~/.claude/skills/gstack && ./setup --team"
|