diff --git a/Resources/crow-workspace-session-settings.json.template b/Resources/crow-workspace-session-settings.json.template new file mode 100644 index 0000000..e48277b --- /dev/null +++ b/Resources/crow-workspace-session-settings.json.template @@ -0,0 +1,82 @@ +{ + "permissions": { + "allow": [ + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh issue edit:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr create:*)", + "Bash(gh pr edit:*)", + "Bash(gh pr review:*)", + "Bash(gh pr comment:*)", + "Bash(gh repo view:*)", + "Bash(gh api graphql:*)", + "Bash(gh api repos:*)", + "Bash(gh auth status:*)", + "Bash(gh search:*)", + + "Bash(GITLAB_HOST=* glab issue view:*)", + "Bash(GITLAB_HOST=* glab mr view:*)", + "Bash(GITLAB_HOST=* glab mr list:*)", + "Bash(GITLAB_HOST=* glab mr create:*)", + "Bash(GITLAB_HOST=* glab auth status:*)", + "Bash(glab issue view:*)", + "Bash(glab mr view:*)", + "Bash(glab mr list:*)", + + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git fetch:*)", + "Bash(git pull:*)", + "Bash(git push:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git branch:*)", + "Bash(git stash:*)", + "Bash(git rebase:*)", + "Bash(git merge:*)", + "Bash(git -C:*)", + "Bash(git ls-remote:*)", + "Bash(git worktree:*)", + + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(which:*)", + "Bash(sleep:*)", + "Bash(mkdir -p:*)", + "Bash(tee:*)", + "Bash(wc:*)", + "Bash(jq:*)", + + "Bash(* > $TMPDIR/*)", + "Bash(* > /tmp/claude/*)", + "Bash(* | tee $TMPDIR/*)", + "Bash(* | tee /tmp/claude/*)", + + "Bash(crow get-session:*)", + "Bash(crow list-worktrees:*)", + "Bash(crow list-links:*)", + "Bash(crow set-status:*)", + + "Read", + "Write", + "Edit", + "Glob", + "Grep" + ] + }, + "sandbox": { + "exclude_commands": ["git", "crow", "gh", "glab"] + } +} diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index 68bcb7b..e8c88b8 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -69,6 +69,12 @@ struct Scaffolder { try setupScript.write(toFile: setupScriptPath, atomically: true, encoding: .utf8) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: setupScriptPath) + // Always overwrite the baseline session-settings template (used by setup.sh + // to seed each new worktree with a `.claude/settings.local.json`) + let sessionSettingsPath = (skillsDir as NSString).appendingPathComponent("session-settings.template.json") + let sessionSettingsTemplate = Self.bundledSessionSettings() + try sessionSettingsTemplate.write(toFile: sessionSettingsPath, atomically: true, encoding: .utf8) + // Always overwrite the review-pr skill with the latest version let reviewSkillPath = (reviewSkillsDir as NSString).appendingPathComponent("SKILL.md") let reviewSkillTemplate = Self.bundledReviewSkill() @@ -151,6 +157,28 @@ struct Scaffolder { """ } + /// The crow-workspace baseline session settings template bundled with the app. + /// Dropped next to setup.sh so each new worktree can seed + /// `.claude/settings.local.json` from it. + static func bundledSessionSettings() -> String { + if let content = loadFromRepo("skills/crow-workspace/session-settings.template.json") { + return content + } + if let url = Bundle.main.url(forResource: "crow-workspace-session-settings.json", withExtension: "template"), + let content = try? String(contentsOf: url) { + return content + } + // Minimal fallback — keeps setup.sh's cp from failing if the template + // ever goes missing. Empty allow list = no extra permissions granted. + return """ + { + "permissions": { + "allow": [] + } + } + """ + } + /// The crow-review-pr SKILL.md template bundled with the app. static func bundledReviewSkill() -> String { if let content = loadFromRepo("skills/crow-review-pr/SKILL.md") { diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index d31a8cb..149247b 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -293,6 +293,16 @@ For cross-workspace setups with multiple repos, call `setup.sh` once per repo: --skip-launch ``` +### Baseline Permissions + +`setup.sh` writes a baseline `.claude/settings.local.json` into each new worktree before launching Claude Code, sourced from `session-settings.template.json` next to the script. The file is gitignored by Claude Code on creation, and its `permissions.allow` array merges with any `.claude/settings.json` shipped by the repo — so it augments rather than overrides the project's own allowlist. + +The baseline covers commands the prompt template instructs sessions to run on step 1 (`gh issue view`, `glab issue view`, `gh pr view`), the git operations a feature branch needs (`status`, `diff`, `add`, `commit`, `push`, …), common shell utilities (`cat`, `grep`, `jq`, `tee`), and redirects to `$TMPDIR` / `/tmp/claude`. + +The template also sets `sandbox.exclude_commands: [git, crow, gh, glab]` so users who have the sandbox enabled don't get an "unsandboxed" prompt for these commands. Crucially the template does **not** set `sandbox.enabled` — that key takes the highest-precedence value across scopes, so omitting it keeps each user's existing sandbox preference (off stays off, on stays on); the exclude list is a no-op when sandbox is disabled. + +If `{worktree}/.claude/settings.local.json` already exists (e.g. checked in by the repo, or left over from a previous setup run), the script leaves it untouched. Edit `session-settings.template.json` to extend the baseline. + ## First Prompt Template IMPORTANT: Always use full absolute paths, never abbreviated (`...`) or home-relative (`~`) paths. diff --git a/skills/crow-workspace/session-settings.template.json b/skills/crow-workspace/session-settings.template.json new file mode 100644 index 0000000..e48277b --- /dev/null +++ b/skills/crow-workspace/session-settings.template.json @@ -0,0 +1,82 @@ +{ + "permissions": { + "allow": [ + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh issue edit:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr create:*)", + "Bash(gh pr edit:*)", + "Bash(gh pr review:*)", + "Bash(gh pr comment:*)", + "Bash(gh repo view:*)", + "Bash(gh api graphql:*)", + "Bash(gh api repos:*)", + "Bash(gh auth status:*)", + "Bash(gh search:*)", + + "Bash(GITLAB_HOST=* glab issue view:*)", + "Bash(GITLAB_HOST=* glab mr view:*)", + "Bash(GITLAB_HOST=* glab mr list:*)", + "Bash(GITLAB_HOST=* glab mr create:*)", + "Bash(GITLAB_HOST=* glab auth status:*)", + "Bash(glab issue view:*)", + "Bash(glab mr view:*)", + "Bash(glab mr list:*)", + + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git fetch:*)", + "Bash(git pull:*)", + "Bash(git push:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git branch:*)", + "Bash(git stash:*)", + "Bash(git rebase:*)", + "Bash(git merge:*)", + "Bash(git -C:*)", + "Bash(git ls-remote:*)", + "Bash(git worktree:*)", + + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(which:*)", + "Bash(sleep:*)", + "Bash(mkdir -p:*)", + "Bash(tee:*)", + "Bash(wc:*)", + "Bash(jq:*)", + + "Bash(* > $TMPDIR/*)", + "Bash(* > /tmp/claude/*)", + "Bash(* | tee $TMPDIR/*)", + "Bash(* | tee /tmp/claude/*)", + + "Bash(crow get-session:*)", + "Bash(crow list-worktrees:*)", + "Bash(crow list-links:*)", + "Bash(crow set-status:*)", + + "Read", + "Write", + "Edit", + "Glob", + "Grep" + ] + }, + "sandbox": { + "exclude_commands": ["git", "crow", "gh", "glab"] + } +} diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index c4a2a49..b834eeb 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -13,6 +13,10 @@ set -uo pipefail # handled explicitly with `if ! ...` or `|| die/log` so that we always # emit structured JSON on failure instead of silently exiting. +# ─── Script Location ───────────────────────────────────────────────────────── + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + # ─── Defaults ──────────────────────────────────────────────────────────────── DEV_ROOT="" @@ -245,6 +249,43 @@ setup_worktree() { log "Worktree created successfully" } +# ─── Baseline Allowlist ────────────────────────────────────────────────────── + +# Drop a baseline `.claude/settings.local.json` into the new worktree so the +# launched Claude Code session doesn't prompt for the routine `gh issue view`, +# `git`, `cat`, `tee`, … commands the prompt template tells it to run. +# +# Source of truth is `session-settings.template.json` next to this script. +# Best-effort: failures are logged, not fatal — a missing template should never +# block worktree setup. Skips if the file already exists (e.g. checked in by +# the repo, or left over from a previous run). +write_session_settings() { + local settings_dir="$WORKTREE_PATH/.claude" + local settings_path="$settings_dir/settings.local.json" + local template_path="$SCRIPT_DIR/session-settings.template.json" + + if [[ ! -f "$template_path" ]]; then + log "Baseline allowlist template not found at $template_path, skipping" + return + fi + + if ! mkdir -p "$settings_dir" 2>/dev/null; then + log "Warning: could not create $settings_dir, skipping baseline allowlist" + return + fi + + if [[ -f "$settings_path" ]]; then + log "$settings_path already exists, leaving untouched" + return + fi + + if cp "$template_path" "$settings_path" 2>/dev/null; then + log "Wrote baseline allowlist to $settings_path" + else + log "Warning: failed to copy baseline allowlist to $settings_path" + fi +} + # ─── Crow Session ──────────────────────────────────────────────────────────── create_session() { @@ -526,6 +567,7 @@ main() { preflight setup_worktree + write_session_settings create_session github_ops write_prompt