diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 84434b2..7ed9805 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,7 +20,7 @@ "url": "https://github.com/choiyounggi/dev-loop.git" }, "homepage": "https://github.com/choiyounggi/dev-loop", - "version": "1.5.0", + "version": "1.6.0", "tags": [ "orchestrator", "verification-loop", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f7e76cd..bc4224c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dev-loop", "description": "loop-orchestrator's verification loop (TDD / PDCA / Reflexion) with the plan step FIXED to a wiki-grounded planning methodology (wiki-plan): every design decision is routed to a bundled semantic-layer wiki before code is written. Adds a knowledge-capture loop — sessions emit verified insights, and knowledge-flush researches, dedups, routes, and opens a wiki PR for owner review.", - "version": "1.5.0", + "version": "1.6.0", "author": { "name": "choiyounggi", "url": "https://github.com/choiyounggi" diff --git a/README.ko.md b/README.ko.md index 8c0983f..b7dfd20 100644 --- a/README.ko.md +++ b/README.ko.md @@ -148,11 +148,13 @@ Orca가 없어도 같은 런이 **raw tmux** 위에서 같은 보호를 파일 분류기는 이를 권한 상승으로 플래그합니다 — 이를 안전하게 만드는 guardrails deny-net을 분류기는 볼 수 없기 때문입니다. 코디네이터 세션이 auto 모드로 돌아간다면 워커 관리 스크립트 3개(`launch-session.sh`, `send-prompt.sh`, -`watch-status.sh`)를 프로젝트의 `.claude/settings.local.json`에 사전 -승인하세요 — 정확한 `permissions.allow` + `autoMode.allow` 스니펫은 -orchestrate SKILL.md의 Preflight 섹션에 있습니다. `safe-cleanup.sh`는 파괴적 -verb가 일반 검토를 계속 받도록 의도적으로 제외했고, 차단당한 코디네이터는 -분류기를 우회하는 대신 스니펫을 보여주고 멈춥니다. +`watch-status.sh`)의 사전 승인이 필요합니다. 코디네이터가 온보딩에서 이를 +처리합니다: 읽기 전용으로 탐침(`install-permission-rules.sh --check`)한 뒤 +규칙이 없으면 **한 번** 물어보고 — 명시적 동의를 받으면 동봉된 인스톨러를 +실행하며(멱등·백업·원자적 쓰기; 절대 조용히 하지 않음), 거절하면 직접 붙여넣을 +스니펫을 보여줍니다. `safe-cleanup.sh`는 파괴적 verb가 일반 검토를 계속 받도록 +의도적으로 제외했고, 차단당한 코디네이터는 분류기를 우회하는 대신 멈추고 다시 +묻습니다. --- diff --git a/README.md b/README.md index 12fb657..1730760 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,15 @@ guardrails escalation contract is identical on both substrates. worker as `claude --permission-mode bypassPermissions`, which an auto-mode permission classifier flags as privilege escalation — it cannot see the guardrails deny-net that makes it safe. If your coordinator session runs in -auto mode, pre-approve the three worker-management scripts -(`launch-session.sh`, `send-prompt.sh`, `watch-status.sh`) in the project's -`.claude/settings.local.json` — the exact `permissions.allow` + -`autoMode.allow` snippet is in the orchestrate SKILL.md's Preflight section. -`safe-cleanup.sh` is deliberately excluded so destructive verbs keep their -normal review, and a blocked coordinator will show you the snippet and stop -rather than work around the classifier. +auto mode, the three worker-management scripts (`launch-session.sh`, +`send-prompt.sh`, `watch-status.sh`) need pre-approval. The coordinator +handles this at onboarding: it probes read-only +(`install-permission-rules.sh --check`), and if the rules are missing it asks +you **once** — on your explicit yes it runs the bundled installer (idempotent, +backed-up, atomic; never silent), otherwise it shows you the snippet to paste +yourself. `safe-cleanup.sh` is deliberately excluded so destructive verbs keep +their normal review, and a blocked coordinator stops and re-asks rather than +working around the classifier. --- diff --git a/skills/orchestrate/SKILL.md b/skills/orchestrate/SKILL.md index acdb211..d8ef298 100644 --- a/skills/orchestrate/SKILL.md +++ b/skills/orchestrate/SKILL.md @@ -45,12 +45,33 @@ auto-mode permission classifier hard-flags as privilege escalation. It cannot see the context that makes this safe (each worker worktree carries a guardrails deny-net that still blocks dangerous commands in bypass mode and escalates `ask` rules to you), so under an auto-mode coordinator the launch may be -DENIED. If that happens: do **not** work around the block — an agent widening -its own permissions is itself classifier-blocked, by design, so only the user -can clear it. Show them this snippet for the project's -`.claude/settings.local.json`, then stop until it is added (substitute the real -plugin cache path; `safe-cleanup.sh` is deliberately absent so destructive -verbs keep their normal review): +DENIED. Handle this at onboarding, not at the first failure: + +1. **Probe once, read-only**, during Preflight: + `sh {SKILL}/scripts/install-permission-rules.sh --check` — exit 0 rules + present (nothing to do), **4** absent, 3 the settings file is malformed + (surface that to the user; fix before anything else). +2. **On 4, ask the user — never install silently.** One question: "orchestrate's + tmux workers launch with permission prompts off (guardrails-sandboxed); + pre-approve the three worker-management scripts in ~/.claude/settings.json?" + Show what it adds (the snippet below). A plugin that widens permissions + without a fresh explicit yes is the supply-chain pattern guardrails exists + to stop — and the fresh consent is also what lets the classifier pass the + write at all. +3. **On an explicit yes**, run `sh {SKILL}/scripts/install-permission-rules.sh` + (idempotent; backs up, refuses a malformed target, atomic write). On no — + or if the installer itself is classifier-blocked — fall back to showing the + snippet for the user to paste themselves, then continue; the launch will + simply prompt (default mode) or deny (auto mode) until it lands. +4. If a launch is DENIED later anyway: do **not** work around the block — an + agent widening its own permissions on its own initiative is itself + classifier-blocked, by design. Re-offer step 2 and stop until the user + decides. + +What the installer adds (equivalently pasteable into +`.claude/settings.local.json` per-project, or `~/.claude/settings.json` +globally; `safe-cleanup.sh` is deliberately absent so destructive verbs keep +their normal review): ```json { diff --git a/skills/orchestrate/scripts/install-permission-rules.sh b/skills/orchestrate/scripts/install-permission-rules.sh new file mode 100755 index 0000000..acc8def --- /dev/null +++ b/skills/orchestrate/scripts/install-permission-rules.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# install-permission-rules.sh — consent-gated installer for the orchestrate +# coordinator's permission rules (see SKILL.md Preflight). +# +# NEVER run this without the user's explicit yes in the CURRENT conversation +# turn. A plugin silently widening permissions is the exact supply-chain +# escalation pattern guardrails exists to stop — the value of this script is +# that the widening happens in one reviewed, idempotent, backed-up step AFTER +# the user agrees, instead of ad-hoc hand edits. (A fresh explicit consent is +# also what lets an auto-mode permission classifier pass the write at all.) +# +# What it installs into a Claude Code settings.json: +# - permissions.allow: three path rules for launch-session.sh / +# send-prompt.sh / watch-status.sh (safe-cleanup.sh is deliberately +# absent — destructive verbs keep their normal review) +# - autoMode.allow: one context rule teaching the classifier why the +# bypassPermissions worker spawn is sanctioned. On a fresh list it is +# seeded as ["$defaults", ]; an existing list only gets the rule +# appended — a user who removed "$defaults" did so deliberately. +# +# usage: install-permission-rules.sh [--check] [target-settings-json] +# default target: $HOME/.claude/settings.json (created if absent) +# --check read-only probe: never creates, writes, or backs up +# +# The path rules embed the real $HOME — permission rules do not expand ~. +# The cache glob (cache/*/dev-loop/*/) covers any marketplace and any +# plugin version, so the rules survive releases. +# +# exit 0 installed, or already present (--check: present) +# exit 1 usage error +# exit 3 target exists but is not valid JSON — REFUSED untouched (a +# malformed settings file silently disables every setting in it, +# so writing over one could hide real damage) +# exit 4 --check only: rules absent +# exit 127 jq not found +set -eu +JQ=$(command -v jq) || { echo "install-permission-rules: jq not found" >&2; exit 127; } + +usage() { echo "usage: install-permission-rules.sh [--check] [target-settings-json]" >&2; exit 1; } + +check=0 +if [ "${1:-}" = "--check" ]; then check=1; shift; fi +case "${1:-}" in -*) usage ;; esac +target="${1:-$HOME/.claude/settings.json}" + +base="$HOME/.claude/plugins/cache/*/dev-loop/*/skills/orchestrate/scripts" +r1="Bash(sh $base/launch-session.sh *)" +r2="Bash(sh $base/send-prompt.sh *)" +r3="Bash(sh $base/watch-status.sh *)" +am='Running the dev-loop orchestrate plugin'"'"'s worker-management scripts (launch-session.sh, send-prompt.sh, watch-status.sh) is allowed, including launch-session.sh starting a tmux worker with `claude --permission-mode bypassPermissions`: the user sanctioned this orchestration workflow, and each worker worktree is sandboxed by groundwork guardrails, which still blocks dangerous commands in bypass mode and escalates `ask` rules to the coordinator. This does NOT extend to safe-cleanup.sh or other destructive commands, which keep their normal review.' + +if [ -f "$target" ]; then + if ! "$JQ" -e . "$target" >/dev/null 2>&1; then + echo "install-permission-rules: REFUSE — '$target' is not valid JSON; fix it first (a malformed settings file silently disables all its settings)" >&2 + exit 3 + fi + cur=$(cat "$target") +else + cur='{}' +fi + +installed=0 +if printf '%s' "$cur" | "$JQ" -e --arg r1 "$r1" --arg r2 "$r2" --arg r3 "$r3" --arg am "$am" ' + ((.permissions.allow // []) | (index($r1) != null and index($r2) != null and index($r3) != null)) + and ((.autoMode.allow // []) | index($am) != null)' >/dev/null 2>&1; then + installed=1 +fi + +if [ "$check" = 1 ]; then + if [ "$installed" = 1 ]; then + echo "install-permission-rules: present in $target" + exit 0 + fi + echo "install-permission-rules: absent from $target" + exit 4 +fi + +if [ "$installed" = 1 ]; then + echo "install-permission-rules: already installed in $target — no changes" + exit 0 +fi + +new=$(printf '%s' "$cur" | "$JQ" --arg r1 "$r1" --arg r2 "$r2" --arg r3 "$r3" --arg am "$am" ' + .permissions.allow = ((.permissions.allow // []) + ([$r1, $r2, $r3] - (.permissions.allow // []))) + | .autoMode.allow = + (if (.autoMode.allow // []) == [] then ["$defaults", $am] + elif (.autoMode.allow | index($am)) == null then .autoMode.allow + [$am] + else .autoMode.allow end)') + +mkdir -p "$(dirname "$target")" +# Backup only when there is a real prior file to protect, then land the new +# content atomically (tmp+mv) so a crash can never leave a half-written file. +if [ -f "$target" ]; then + bak="$target.bak.$(date +%s)" + cp "$target" "$bak" +else + bak="" +fi +tmp="$target.tmp.$$" +trap 'rm -f "$tmp"' EXIT +printf '%s\n' "$new" > "$tmp" +# Paranoid post-check: never install something jq cannot re-read. +"$JQ" -e . "$tmp" >/dev/null 2>&1 || { echo "install-permission-rules: internal error — generated JSON invalid, aborting" >&2; exit 3; } +mv "$tmp" "$target" + +if [ -n "$bak" ]; then + echo "install-permission-rules: installed into $target (backup: $bak)" +else + echo "install-permission-rules: installed into $target (new file)" +fi diff --git a/tests/install-permission-rules.bats b/tests/install-permission-rules.bats new file mode 100644 index 0000000..9e0c97f --- /dev/null +++ b/tests/install-permission-rules.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# install-permission-rules.sh — consent-gated coordinator permission installer. +# +# The script merges the orchestrate coordinator's permission rules (three +# path allows + one autoMode classifier rule) into a Claude Code settings.json. +# It must be idempotent, refuse malformed targets, back up before writing, and +# never touch the file in --check mode. HOME is overridden per test so the +# generated rule strings are deterministic and the real user settings can +# never be read or written by a test run. + +setup() { + IPR="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/install-permission-rules.sh" + WORK="${BATS_TEST_TMPDIR}/work" + mkdir -p "$WORK" + TARGET="$WORK/settings.json" + FAKE_HOME="${BATS_TEST_TMPDIR}/home" + mkdir -p "$FAKE_HOME" +} + +# helper: the launch-session path rule the script must generate for FAKE_HOME +expected_rule1() { + printf 'Bash(sh %s/.claude/plugins/cache/*/dev-loop/*/skills/orchestrate/scripts/launch-session.sh *)' "$FAKE_HOME" +} + +@test "fresh install: missing target file is created with all rules and valid JSON" { + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 0 ] + [ -f "$TARGET" ] + jq -e . "$TARGET" >/dev/null + # three path rules present, keyed on the real (fake) home dir + [ "$(jq -r '.permissions.allow | length' "$TARGET")" -eq 3 ] + jq -e --arg r "$(expected_rule1)" '.permissions.allow | index($r) != null' "$TARGET" >/dev/null + # autoMode gets $defaults first, then our context rule + [ "$(jq -r '.autoMode.allow[0]' "$TARGET")" = '$defaults' ] + jq -e '.autoMode.allow[1] | contains("bypassPermissions")' "$TARGET" >/dev/null + jq -e '.autoMode.allow[1] | contains("safe-cleanup.sh")' "$TARGET" >/dev/null +} + +@test "idempotent: second run reports already installed, changes nothing, makes no backup" { + env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + before=$(cat "$TARGET") + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 0 ] + printf '%s' "$output" | grep -q "already installed" + [ "$(cat "$TARGET")" = "$before" ] + # a no-op run must not leave a backup + [ "$(find "$WORK" -name 'settings.json.bak.*' | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "merge: existing allow rules and settings keys are preserved, missing rules appended" { + printf '{"permissions":{"allow":["Bash(git:*)"],"defaultMode":"default"},"model":"opus"}' > "$TARGET" + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 0 ] + # pre-existing entries survive, in place + [ "$(jq -r '.permissions.allow[0]' "$TARGET")" = 'Bash(git:*)' ] + [ "$(jq -r '.permissions.defaultMode' "$TARGET")" = 'default' ] + [ "$(jq -r '.model' "$TARGET")" = 'opus' ] + [ "$(jq -r '.permissions.allow | length' "$TARGET")" -eq 4 ] +} + +@test "merge: existing autoMode.allow gets our rule appended without injecting \$defaults" { + # a user who removed $defaults did so deliberately — the installer must not re-add it + printf '{"autoMode":{"allow":["my custom rule"]}}' > "$TARGET" + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 0 ] + [ "$(jq -r '.autoMode.allow | length' "$TARGET")" -eq 2 ] + [ "$(jq -r '.autoMode.allow[0]' "$TARGET")" = 'my custom rule' ] + run jq -e '.autoMode.allow | index("$defaults")' "$TARGET" + [ "$status" -ne 0 ] +} + +@test "malformed target is refused untouched with exit 3 and no backup" { + printf '{ not json' > "$TARGET" + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 3 ] + printf '%s' "$output" | grep -qi "refuse" + [ "$(cat "$TARGET")" = '{ not json' ] + [ "$(find "$WORK" -name 'settings.json.bak.*' | wc -l | tr -d ' ')" -eq 0 ] +} + +@test "backup: a modifying run leaves exactly one timestamped backup of the prior content" { + printf '{"permissions":{"allow":["Bash(git:*)"]}}' > "$TARGET" + run env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + [ "$status" -eq 0 ] + [ "$(find "$WORK" -name 'settings.json.bak.*' | wc -l | tr -d ' ')" -eq 1 ] + bak=$(find "$WORK" -name 'settings.json.bak.*') + [ "$(cat "$bak")" = '{"permissions":{"allow":["Bash(git:*)"]}}' ] +} + +@test "--check: reports absent (4) without creating or writing anything" { + run env HOME="$FAKE_HOME" sh "$IPR" --check "$TARGET" + [ "$status" -eq 4 ] + [ ! -e "$TARGET" ] +} + +@test "--check: reports present (0) after an install, file untouched" { + env HOME="$FAKE_HOME" sh "$IPR" "$TARGET" + before=$(cat "$TARGET") + run env HOME="$FAKE_HOME" sh "$IPR" --check "$TARGET" + [ "$status" -eq 0 ] + [ "$(cat "$TARGET")" = "$before" ] +} + +@test "--check on a malformed target is absent-with-refusal (3), never a crash" { + printf 'nope' > "$TARGET" + run env HOME="$FAKE_HOME" sh "$IPR" --check "$TARGET" + [ "$status" -eq 3 ] +} + +@test "missing jq exits 127 (boundary)" { + run env HOME="$FAKE_HOME" PATH="/nonexistent" sh "$IPR" "$TARGET" + [ "$status" -eq 127 ] +} + +@test "default target: no argument installs into \$HOME/.claude/settings.json" { + run env HOME="$FAKE_HOME" sh "$IPR" + [ "$status" -eq 0 ] + [ -f "$FAKE_HOME/.claude/settings.json" ] + [ "$(jq -r '.permissions.allow | length' "$FAKE_HOME/.claude/settings.json")" -eq 3 ] +} + +@test "usage: an unknown flag is rejected with exit 1" { + run env HOME="$FAKE_HOME" sh "$IPR" --frobnicate "$TARGET" + [ "$status" -eq 1 ] + printf '%s' "$output" | grep -q "usage" +}