Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
12 changes: 7 additions & 5 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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가 일반 검토를 계속 받도록
의도적으로 제외했고, 차단당한 코디네이터는 분류기를 우회하는 대신 멈추고 다시
묻습니다.

---

Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
33 changes: 27 additions & 6 deletions skills/orchestrate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
110 changes: 110 additions & 0 deletions skills/orchestrate/scripts/install-permission-rules.sh
Original file line number Diff line number Diff line change
@@ -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", <rule>]; 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
126 changes: 126 additions & 0 deletions tests/install-permission-rules.bats
Original file line number Diff line number Diff line change
@@ -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"
}
Loading