diff --git a/.gitignore b/.gitignore index d74a5e3..92dc379 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Explicitly allow tracked template files and directories. !.gitignore +!.gitattributes !README.md !LICENSE !CLAUDE.md @@ -22,6 +23,17 @@ !scripts/** !tests/ !tests/** + +# Allow only the project-local design-artifact directories under docs/ +# (plans and research), not the whole docs/ tree. Gitignore requires +# un-ignoring each parent directory level before its children, so the +# chain below is intentionally explicit rather than a single !docs/**. +!docs/ +!docs/plans/ +!docs/plans/** +!docs/research/ +!docs/research/** + !.github/ !.github/** @@ -40,7 +52,7 @@ settings.json settings.local.json .claude.json .mcp.json -plans/ +/plans/ projects/ history.jsonl *.log diff --git a/CLAUDE.md b/CLAUDE.md index 38e0e94..f475e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ PM-turned-engineer. Quality and learning over speed. Always explain WHY behind a Co-Authored-By: Claude - No implementation without an approved design. If uncertain, ask first. - Two human checkpoints are non-negotiable: after design, before PR. -- Each task maps to exactly one atomic Conventional Commit, made by the implementer that did the task. `/ship` verifies the series; it never creates a catch-all or squashed commit. +- Each task maps to exactly one atomic Conventional Commit made by the implementer that did the task, or `commit: none` for unversioned/external targets (verification-only gates, no commit). `/ship` verifies the series; it never creates a catch-all or squashed commit. - A PR is opened only after the feature/milestone is fully built AND the quality gate has passed. No PR on a red gate; no PR mid-build. -- Mode transitions: `/design` runs in plan mode (may write only `~/.claude/plans/-*.md`); the post-design checkpoint is the exit-plan-mode point; `/build` and `/ship` run in execute mode. +- Mode transitions: `/design` runs in plan mode; on plan approval it writes the artifact set to `/docs/plans//` (project copy canonical; plans-dir drafts banner-stamped; plans dir for meta-tooling/gitignored-docs work; no-repo case prompts to `git init` or stay in the plans dir); the post-design checkpoint precedes `/build`; `/build` commits the set as a scoped `docs():` commit on the feature branch (not necessarily the leading commit in the series); `/build` and `/ship` run in execute mode. - `/ship` ends by offering `/compound`. Process-rule edits (CLAUDE.md, `.claude/rules`) are never committed into the feature PR. - Model routing and workflow usage follow ~/.claude/rules/orchestration.md. diff --git a/README.md b/README.md index 9b9f9f8..7cb6bab 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,13 @@ would cost 50K+ and degrade quality as the window fills. ``` /design → research + draft + task breakdown + review loop - ↓ (checkpoint 1 — you approve, then exit plan mode) + ↓ (checkpoint 1 — /design exits plan mode itself, provisionally, then writes + the approved artifacts into docs/plans//; you approve there, after + the write — /build is still the real go/no-go) /build [] → implement via subagents, one atomic commit per task + (plus a `docs():` artifacts commit, not + necessarily leading, when a project design copy + was promoted) ↓ (checkpoint 2 — you approve the commit series) /ship → verify commits, quality gate, push, open PR ↓ (offered, optional) @@ -209,6 +214,17 @@ customized since the last install surfaces as a per-category diff/prompt — it's **skipped by default** and only overwritten if you explicitly consent; nothing you've changed is clobbered silently. +**Upgrading into an existing install**: because differing files are skipped by +default, an adopter who already had `~/.claude` set up before this feature +shipped won't pick up the new `design-scope-guard.sh` hook or the changed +`/design` skill automatically — `/setup`'s diff-then-consent flow treats an +existing file that differs from the new template as skip-by-default, not an +automatic overwrite. Choose overwrite at the `hooks/` and `skills/` category +prompts (or run `bash scripts/install.sh --force`) to receive both. The new +hook installs cleanly onto a fresh copy of `hooks/`; `merge-settings.sh` adds +its `PreToolUse` registration to `settings.json` during the settings merge +step, same as any other hook. + ## 10. Uninstalling There's no uninstall script yet (deliberately deferred — see below); removal @@ -318,3 +334,39 @@ route-up-for-hard-work, the ≥5-agent workflow threshold), the review-loop semantics (`NO_VERDICT` vs `NEEDS_WORK`, counter-model review, ground-truth verification), and the hooks — these are the parts built from real failure modes and are worth keeping close to as-is. + +## 14. Project-local design docs (adopter notes) + +`/design` promotes its approved artifact set (`-design-draft.md`, +`-tasks.md`, `-research-*.md`) out of `~/.claude/plans/` and into +the adopting project's own `$ROOT/docs/plans//`, so a design lives with +the code it describes and is reviewable from a phone/synced Files app before +`/build` runs — see `WORKFLOW.md`'s Mode Transitions table for the full +step-by-step sequence. A few things to know if your project isn't a typical +git repo: + +- **Gitignored `docs/`** — if `docs/` (or specifically `docs/plans/`) is + gitignored in your project, the promotion write in `/design`'s guarded + window and `/build`'s `docs(): add design + research artifacts` + commit (not necessarily the leading commit in the series) both skip, + explicitly and by design — artifacts stay in the plans dir + (`~/.claude/plans/`, or `$CLAUDE_CONFIG_DIR/plans/` if you've overridden + it) as the canonical copy, exactly how `/design` behaved before this + feature. +- **A published `docs/` site** (Docusaurus, mkdocs, GitHub Pages, etc.) — if + your project already builds `docs/` into a live site, promoting WIP planning + artifacts into `docs/plans//` would land them inside that published + tree. Relocate the target directory by editing the write logic in + `skills/design/SKILL.md`'s Step 6 — and the matching path patterns in + `hooks/design-scope-guard.sh`, or the write will be blocked — to a directory + outside your published tree. +- **Forking this workflow repo itself** — a fork of `claude-code-setup` + behaves like any other adopting project for design routing: unless `$ROOT` + resolves to your actual `${CLAUDE_CONFIG_DIR:-$HOME/.claude}`, artifacts + promote into the fork's own `docs/plans//` like anywhere else. The + meta-tooling exception (artifacts stay in the plans dir) applies only when + you're editing your live `~/.claude` config in place. +- **Upgrading an existing install** — see [Updating](#9-updating): this + feature ships a new hook and a changed `/design` skill, both skipped by + default on an already-customized config unless you explicitly choose to + overwrite. diff --git a/WORKFLOW.md b/WORKFLOW.md index b90433c..ba3eea2 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -47,6 +47,7 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y | Branch protection | Hook: blocks push *to* main/master + force push (by destination, not branch name) | `~/.claude/hooks/protect-branches.sh` | | Secrets protection | Hook: blocks edits to .env/keys/credentials/secrets (broadened) | `~/.claude/hooks/protect-secrets.sh` | | Delegate guard | Hook: session-scoped; blocks orchestrator source edits while `/build` active | `~/.claude/hooks/orchestrator-delegate-guard.sh` | +| Design scope guard | Hook: session-scoped; confines `/design`'s post-approval Write\|Edit calls (not a hard filesystem guarantee) to design artifact paths (`docs/plans/` — where `/design` writes both its design draft and research files — `docs/research/`, a separately allowlisted research-output path not written by `/design`, plans dir), sentinel-armed, same trust class as the delegate guard | `~/.claude/hooks/design-scope-guard.sh` | | Syntax check | Hook: multi-language (py/js/sh/json/swift), surfaces errors | `~/.claude/hooks/syntax-check.sh` | --- @@ -63,11 +64,13 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y 3. Spawns grounding subagents in parallel: `Explore` (codebase map, **`haiku`** — fan-out navigation) + two `researcher` (`sonnet`, 3-angle methodology) - Only structured summaries return to main context (~500-1K tokens each) - Raw research (10-50K tokens) stays in subagent context -4. Synthesizes a design draft **and** `-tasks.md` (adaptive task count — 1 to many, never a fixed range; each task: disjoint files, assigned model, verification, one atomic Conventional commit) +4. Synthesizes a design draft **and** `-tasks.md` (adaptive task count — 1 to many, never a fixed range; each task: disjoint files, assigned model, verification, one atomic Conventional commit — or `commit: none` for unversioned/external targets, verification-only) 5. **design-reviewer** loop (fresh context each pass, continues until findings converge, escalates after 5 iterations) for consistency/codebase-fit/best-practices, then **direction-reviewer** once (`opus`, premise/problem-fit) -6. **STOPS for your approval** — presents both verdicts + tasks path; this is the exit-plan-mode boundary +6. `/design` calls **`ExitPlanMode` itself** — provisional and skill-invoked, not the real go/no-go; approving here only continues the same turn into the guarded write below (rejecting stays in plan mode and re-runs the loop) +7. **Guarded promotion write** (execute mode, same turn; session-scoped sentinel via `design-scope-guard.sh`) — writes the approved artifact set into `$ROOT/docs/plans//`, unless `$ROOT` is the config repo itself or `docs/plans/` is gitignored there, in which case artifacts stay in the plans dir +8. **STOPS for your approval** — the real checkpoint, now *after* the write, on the project copy; presents both verdicts + the artifact path -**Iteration**: feedback regenerates the design *and* `-tasks.md`, re-runs the review loop + direction review, and stops again at a single checkpoint. +**Iteration**: feedback given before the provisional `ExitPlanMode` approval iterates entirely on the plans-dir copies (regenerate design + `-tasks.md`, re-run the review loop + direction review). Feedback given at the post-write checkpoint re-arms the sentinel and iterates on the project copies in `docs/plans//` instead — the plans-dir drafts are inert scratch by then. Either path stops again at a single checkpoint. **Context impact**: Research stays out of main context. Only summaries enter. @@ -76,9 +79,9 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y **What it does**: Orchestrates implementation using subagents. The main agent NEVER writes code and NEVER re-decomposes — it consumes `-tasks.md`, delegates, and reviews. Runs in execute mode. **How it works**: -1. **Pre-flight**: Resolves the tasks file (arg / newest-by-mtime-with-confirm), creates feature branch, writes the session-scoped sentinel +1. **Pre-flight**: Resolves the tasks file — project copy first (`$ROOT/docs/plans//-tasks.md`), falling back to the legacy `~/.claude/plans/` (arg / newest-by-mtime-with-confirm) — creates feature branch, commits a promoted project design copy as a `docs(): add design + research artifacts` commit, then writes the session-scoped sentinel 2. **Consume tasks**: Executes exactly the tasks `-tasks.md` defines — adaptive count (1 to many), in `depends_on` order, independent ones in parallel -3. **Implement**: Spawns a context-pinned **implementer** per task (only its task entry + relevant excerpt); the implementer makes **one atomic Conventional commit per task** (co-author trailer; no push) +3. **Implement**: Spawns a context-pinned **implementer** per task (only its task entry + relevant excerpt); the implementer makes **one atomic Conventional commit per task** (co-author trailer; no push) — except `commit: none` tasks (unversioned/external targets), which are verification-only 4. **Review**: Spawns **reviewer** subagent that reads files FRESH from disk and checks commit atomicity (no implementer bias) 5. **Iterate**: If reviewer finds issues: - Iteration 1-2: Spawns NEW implementer with reviewer's feedback @@ -93,7 +96,7 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y **How it works**: 1. Verifies NOT on main (refuses if so) -2. **Verifies** the atomic per-task Conventional commit series (does NOT squash or author a catch-all commit; stops on stray uncommitted changes) +2. **Verifies** the atomic per-task Conventional commit series (does NOT squash or author a catch-all commit; stops on stray uncommitted changes) — a `docs():` artifacts commit is exempt from the one-commit-per-task accounting 3. Updates **product** docs (README, ARCHITECTURE, CHANGELOG) if needed — distinct from `/compound`'s process-rule capture 4. Quality gate must pass; opens PR only after the feature is fully built AND green 5. Pushes branch, creates PR via `gh pr create`, then **offers `/compound`** (HITL; never commits process-rule edits into this PR) @@ -106,7 +109,9 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y ``` /design → research + draft + -tasks.md + design-reviewer loop + direction-reviewer - ↓ (you approve — exit plan mode here) + ↓ (/design exits plan mode itself, provisionally, then writes the approved + artifacts into docs/plans// — you approve there, after the write; + /build is still the real go/no-go) /build [] → consume tasks.md; one atomic commit per task; reviewer ↓ (you approve the commit series) /ship → verify series, product-doc updates, quality gate, push, PR @@ -120,16 +125,23 @@ never lands process-rule edits inside the feature PR. ## Mode Transitions +`/design` is a **hybrid**: plan mode for its research/draft/review loop (Steps 0-5), +then a short guarded execute-mode window (Step 6) that writes the approved artifacts +into the project, followed by the real human checkpoint (Step 7) — the checkpoint now +comes *after* that write, not before it. + | Stage | Mode | Notes | |---|---|---| -| `/design` | **plan** | Documented exception: may write only `~/.claude/plans/-*.md` (judgment call, bounded to that dir) | -| post-design checkpoint | — | **The exit-plan-mode boundary.** You approve, then leave plan mode | +| `/design` Steps 0-5 (research, draft, review loop) | **plan** | Documented exception: may write only `~/.claude/plans/-*.md` (judgment call, bounded to that dir) | +| `/design` Step 5 `ExitPlanMode` | — | **Provisional, skill-invoked.** `/design` calls `ExitPlanMode` itself; approving here is administrative — it only continues the same turn into Step 6, it is not the go/no-go | +| `/design` Step 6 (execute, same turn) | **execute**, guarded | `design-scope-guard.sh`'s session-scoped sentinel confines Write\|Edit calls (not a hard filesystem guarantee) to `docs/plans//` (where `/design`'s own research files land, alongside the design draft + tasks), `docs/research/` (a separately allowlisted research-output path, not written by `/design`), and the plans dir while the approved artifact set is promoted into `$ROOT/docs/plans//` (also `~/.claude/plans/-*.md` during this window) | +| `/design` Step 7 checkpoint | — | **The real human checkpoint**, now after the promotion write, on the project copy. You approve or iterate here — `/build` remains the true go/no-go | | `/build`, `/ship` | **execute** (default/acceptEdits) | `disable-model-invocation: true`; run explicitly after approval | The shipped template sets `defaultMode: "auto"` — a convenience key, deliberately withheld from merges into existing configs. The plan-first guarantee comes from -`/design` running in plan mode and the post-design checkpoint being the exit-plan-mode -boundary, not from a global `"plan"` default. +`/design`'s Steps 0-5 running in plan mode and Step 6's write window being confined by +`design-scope-guard.sh`, not from a global `"plan"` default. ## Model Usage Map @@ -164,7 +176,7 @@ top balanced level). - **Output**: Structured findings with URLs, confidence levels, consensus, gaps ### implementer (`sonnet` default / `opus` per-call) -- **Purpose**: Implement one task; make exactly one atomic Conventional commit for it +- **Purpose**: Implement one task; make exactly one atomic Conventional commit for it — or, for `commit: none` tasks (unversioned/external targets), verification-only with no commit - **Model**: `sonnet`; the design assigns `opus` per-call for >5-file / long-horizon / cross-cutting tasks - **Tools**: Read, Write, Edit, Glob, Grep, Bash, context7 - **Cannot**: `git push`, `gh pr create`, checkout main (commits ARE allowed and required) @@ -201,6 +213,7 @@ All hooks are in `~/.claude/settings.json` and `~/.claude/hooks/`. They provide | protect-branches.sh | PreToolUse (Bash, git) | Blocks pushes whose *destination* is main/master + force push; allows feature branches named like `feat/main-*` | | protect-secrets.sh | PreToolUse (Write\|Edit) | Blocks edits to .env/.envrc, credentials, secrets, keys (.pem/.key/.p8/.pfx/.jks/.keystore), id_rsa, .aws/.ssh/.gnupg, service-account/gcp json, Config.swift | | orchestrator-delegate-guard.sh | PreToolUse (Write\|Edit) | While `/build` active, blocks orchestrator (main-thread) source edits; **session-scoped** (`/tmp/claude-orchestrator-active.$SESSION_ID`) with TTL self-heal | +| design-scope-guard.sh | PreToolUse (Write\|Edit) | While `/design`'s Step 6 write window is active, confines Write\|Edit calls (not a hard filesystem guarantee) to design artifact paths (`docs/plans/` — where `/design` writes both the draft and its research files — `docs/research/`, a separately allowlisted research-output path not written by `/design`, the plans dir); **session-scoped** (`/tmp/claude-design-active.$SESSION_ID`), same trust class + TTL self-heal as the delegate guard | | syntax-check.sh | PostToolUse (Write\|Edit) | Multi-language check (py/js/sh/json/swift), surfaces real errors; never blocks | | Notification | Notification event | macOS notification when Claude needs attention (macOS-only, uses `osascript`) | @@ -209,6 +222,7 @@ All hooks are in `~/.claude/settings.json` and `~/.claude/hooks/`. They provide - `PostToolUse` hooks run AFTER. Informational only. - Hooks load at session start. **Changes require restarting Claude Code.** - The delegate guard is session-scoped via `CLAUDE_CODE_SESSION_ID` (matches the hook-stdin `session_id`) and self-heals a stale sentinel after `TTL_MINUTES` (default 90). **Session isolation requires Claude Code ≥ v2.1.132**; below that it falls back to the legacy global path with the TTL as sole guard. +- `design-scope-guard.sh` mirrors the same session-scoped sentinel pattern, armed/disarmed by `/design` itself around its Step 6 promotion write (TTL 120 min — generous, since the sentinel only needs to survive a single short write window rather than a whole `/build` run). - Debug: `claude --debug` | View loaded hooks: `/hooks` --- diff --git a/hooks/design-scope-guard.sh b/hooks/design-scope-guard.sh new file mode 100755 index 0000000..95dd728 --- /dev/null +++ b/hooks/design-scope-guard.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# PreToolUse(Write|Edit) scope guard. While /design is active for THIS session (its +# short post-approval write window), edits are confined to design artifact paths. +# Session-scoped + self-healing sentinel, structurally mirroring +# hooks/orchestrator-delegate-guard.sh. Honest strength claim: the sentinel is +# model-removable in principle — same trust class as the delegate guard. +# +# TTL_MINUTES: a sentinel older than this is treated as stale (a crashed/killed +# /design) and reaped, then the edit is allowed. /design's write window is short, +# so this is a generous ceiling rather than an expected duration. +# +# Residual limits (honest disclosure, not exhaustive): +# (a) this hook fires on PreToolUse(Write|Edit) only -- it does not intercept +# Bash, so `cp`/shell redirection/`ln -s` can still write outside the +# allowlist during the guarded window. +# (b) the require-absolute-path check below is defense-in-depth best practice, +# not a schema-guaranteed invariant of tool_input.file_path. +# (c) the checks below sit after the agent_type != main exemption, so this +# hardening is main-thread-only by design -- delegated subagent edits +# remain exempt. +# (d) matching is lexical (string prefix/glob) only, with no symlink +# resolution -- a symlink placed inside an allowlisted dir that points +# outside it defeats the allowlist; out of scope for a Write|Edit hook. +# (e) the project-relative allowances are anchored to the session project root +# derived from the payload cwd. cwd follows `cd` within the session, so +# this binds to the session's *current* project -- a real narrowing (an +# unrelated repo's docs/plans/ no longer matches), not cryptographic +# confinement. +TTL_MINUTES=120 + +INPUT=$(cat) + +# Fast path (no jq needed): if no sentinel file of any kind exists, /design is not +# active anywhere on the machine, so there is nothing to guard — exit before we +# need to parse anything. This is the common case for every edit outside a /design run. +if ! ls /tmp/claude-design-active* >/dev/null 2>&1; then + exit 0 +fi + +# Past this point a sentinel might be active, so we need jq to read session_id / +# agent_type / file_path from stdin. Fail closed rather than silently allowing +# an edit the guard can no longer evaluate. +command -v jq >/dev/null 2>&1 || { echo "jq required for this hook — install jq or remove it from settings.json" >&2; exit 2; } + +# Malformed JSON yields an empty session_id below, which collapses the sentinel +# path to the unsuffixed legacy path; if nothing is there the sentinel loop +# finds nothing and the hook would exit 0 at the sentinel stage BEFORE any +# file_path check ever runs. Reject unparseable input here instead (fail closed). +echo "$INPUT" | jq empty 2>/dev/null || { echo "design-scope-guard — unparseable hook input, refusing (fail closed)" >&2; exit 2; } + +SID=$(echo "$INPUT" | jq -r '.session_id // empty') +SENTINEL="/tmp/claude-design-active${SID:+.$SID}" +LEGACY="/tmp/claude-design-active" + +# Find an active, non-stale sentinel: prefer the session-scoped one, fall back to the +# legacy global path for backward compatibility. Reap stale ones (self-heal). +ACTIVE="" +for S in "$SENTINEL" "$LEGACY"; do + [ -f "$S" ] || continue + if [ -n "$(find "$S" -mmin +"$TTL_MINUTES" 2>/dev/null)" ]; then + rm -f "$S" + continue + fi + ACTIVE="$S"; break +done +[ -n "$ACTIVE" ] || exit 0 + +# agent_type is present in hook stdin only inside a subagent (per Claude Code hooks +# docs; for custom agents it is the frontmatter `name`), absent on the main thread. +# A delegated subagent is allowed to edit (accepted residual exemption). +AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // empty') +if [ -n "$AGENT_TYPE" ] && [ "$AGENT_TYPE" != "main" ]; then + exit 0 +fi + +FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty') + +[ -z "$FILE" ] && { echo "design-scope-guard — empty file_path, refusing (fail closed)" >&2; exit 2; } +case "$FILE" in + ../*|*/../*|*/..|..) echo "design-scope-guard — path traversal (..) refused" >&2; exit 2 ;; +esac +case "$FILE" in + /*) ;; + *) echo "design-scope-guard — non-absolute path refused" >&2; exit 2 ;; +esac + +# Anchor the project-relative allowances to THIS session's project root, so the +# docs/plans/, docs/research/, and .gitattributes shapes cannot match an +# unrelated repo elsewhere on disk. Derive the root from the payload cwd: git +# toplevel when cwd is inside a repo, else cwd itself (bats fixtures aren't +# repos). Sanitize first -- an empty, non-absolute, or ..-containing cwd yields +# an empty ROOT, and the project-relative arm below is skipped entirely (fail +# closed for that branch only) rather than collapsing "$ROOT"/... to a bare +# /... glob. +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') +ROOT="" +case "$CWD" in + ""|../*|*/../*|*/..|..) ;; + /*) ROOT=$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null); [ -n "$ROOT" ] || ROOT="$CWD" ;; +esac + +# /design may write plan and research artifacts, and the project-root +# .gitattributes (skills/build writes only "$ROOT/.gitattributes" -- the +# docs/plans/** linguist-generated marker -- so only the root file is allowed, +# never */.gitattributes anywhere). These arms are root-anchored literal +# prefixes, never bare */... globs (which this repo has repeatedly regressed +# into an unanchored allowlist bypass). +if [ -n "$ROOT" ]; then + case "$FILE" in + "$ROOT"/docs/plans/*|"$ROOT"/docs/research/*|"$ROOT"/.gitattributes) exit 0 ;; + esac +fi + +# Config-dir and scratch allowances are not project-scoped and stay as-is. When +# CLAUDE_CONFIG_DIR is set (adopters using a non-default config location), its +# plans/ dir is editable too, alongside the default $HOME/.claude/plans/. +case "$FILE" in + "$HOME"/.claude/plans/*|/tmp/*) exit 0 ;; +esac +if [ -n "$CLAUDE_CONFIG_DIR" ]; then + case "$FILE" in + "$CLAUDE_CONFIG_DIR"/plans/*) exit 0 ;; + esac +fi + +echo "/design is active — writes are confined to this project's design artifact paths (${ROOT:-}/docs/plans/, docs/research/, .gitattributes) plus the plans dir; paths outside the session project are refused" >&2 +exit 2 diff --git a/hooks/orchestrator-delegate-guard.sh b/hooks/orchestrator-delegate-guard.sh index 225885f..e4d23cb 100755 --- a/hooks/orchestrator-delegate-guard.sh +++ b/hooks/orchestrator-delegate-guard.sh @@ -9,6 +9,24 @@ # and reaped, then the edit is allowed. /build re-touches its sentinel at the start of # every task, so only a stalled/killed run trips this. Heuristic for tuning: # max(expected_task_count * 10, 45), capped at 120. This is the single adjustment point. +# +# Residual limits (honest disclosure, not exhaustive): +# (a) this hook fires on PreToolUse(Write|Edit) only -- it does not intercept +# Bash, so `cp`/shell redirection/`ln -s` can still write outside the +# allowlist during the guarded window. +# (b) the require-absolute-path check below is defense-in-depth best practice, +# not a schema-guaranteed invariant of tool_input.file_path. +# (c) the checks below sit after the agent_type != main exemption, so this +# hardening is main-thread-only by design -- delegated subagent edits +# remain exempt. +# (d) matching is lexical (string prefix/glob) only, with no symlink +# resolution -- a symlink placed inside an allowlisted dir that points +# outside it defeats the allowlist; out of scope for a Write|Edit hook. +# (e) the project-relative allowances are anchored to the session project root +# derived from the payload cwd. cwd follows `cd` within the session, so +# this binds to the session's *current* project -- a real narrowing (an +# unrelated repo's docs/plans/ no longer matches), not cryptographic +# confinement. TTL_MINUTES=90 INPUT=$(cat) @@ -25,6 +43,12 @@ fi # an edit the guard can no longer evaluate. command -v jq >/dev/null 2>&1 || { echo "jq required for this hook — install jq or remove it from settings.json" >&2; exit 2; } +# Malformed JSON yields an empty session_id below, which collapses the sentinel +# path to the unsuffixed legacy path; if nothing is there the sentinel loop +# finds nothing and the hook would exit 0 at the sentinel stage BEFORE any +# file_path check ever runs. Reject unparseable input here instead (fail closed). +echo "$INPUT" | jq empty 2>/dev/null || { echo "orchestrator-delegate-guard — unparseable hook input, refusing (fail closed)" >&2; exit 2; } + SID=$(echo "$INPUT" | jq -r '.session_id // empty') SENTINEL="/tmp/claude-orchestrator-active${SID:+.$SID}" LEGACY="/tmp/claude-orchestrator-active" @@ -52,11 +76,47 @@ fi FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty') -# The orchestrator may still touch its own plan/design drafts and scratch space. -# When CLAUDE_CONFIG_DIR is set (adopters using a non-default config location), -# its plans/ dir is editable too, alongside the default $HOME/.claude/plans/. +[ -z "$FILE" ] && { echo "orchestrator-delegate-guard — empty file_path, refusing (fail closed)" >&2; exit 2; } +case "$FILE" in + ../*|*/../*|*/..|..) echo "orchestrator-delegate-guard — path traversal (..) refused" >&2; exit 2 ;; +esac +case "$FILE" in + /*) ;; + *) echo "orchestrator-delegate-guard — non-absolute path refused" >&2; exit 2 ;; +esac + +# Anchor the project-relative allowances to THIS session's project root, so the +# promoted docs/plans/, docs/research/, and .gitattributes shapes cannot match +# an unrelated repo elsewhere on disk. Derive the root from the payload cwd: git +# toplevel when cwd is inside a repo, else cwd itself (bats fixtures aren't +# repos). Sanitize first -- an empty, non-absolute, or ..-containing cwd yields +# an empty ROOT, and the project-relative arm below is skipped entirely (fail +# closed for that branch only) rather than collapsing "$ROOT"/... to a bare +# /... glob. +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') +ROOT="" +case "$CWD" in + ""|../*|*/../*|*/..|..) ;; + /*) ROOT=$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null); [ -n "$ROOT" ] || ROOT="$CWD" ;; +esac + +# The orchestrator may still touch its own promoted plan/design drafts and the +# project-root .gitattributes (skills/build writes only "$ROOT/.gitattributes" +# -- the docs/plans/** linguist-generated marker -- so only the root file is +# allowed, never */.gitattributes anywhere). These arms are root-anchored +# literal prefixes, never bare */... globs (which this repo has repeatedly +# regressed into an unanchored allowlist bypass). +if [ -n "$ROOT" ]; then + case "$FILE" in + "$ROOT"/docs/plans/*|"$ROOT"/docs/research/*|"$ROOT"/.gitattributes) exit 0 ;; + esac +fi + +# Config-dir and scratch allowances are not project-scoped and stay as-is. When +# CLAUDE_CONFIG_DIR is set (adopters using a non-default config location), its +# plans/ dir is editable too, alongside the default $HOME/.claude/plans/. case "$FILE" in - "$HOME"/.claude/plans/*|/tmp/*|"") exit 0 ;; + "$HOME"/.claude/plans/*|/tmp/*) exit 0 ;; esac if [ -n "$CLAUDE_CONFIG_DIR" ]; then case "$FILE" in @@ -64,5 +124,5 @@ if [ -n "$CLAUDE_CONFIG_DIR" ]; then esac fi -echo "Blocked: /build is active (session ${SID:-global}); the orchestrator must not edit source files directly. Spawn an implementer subagent for this change. Plan files under ~/.claude/plans/ (or \$CLAUDE_CONFIG_DIR/plans/ if set) remain editable." >&2 +echo "Blocked: /build is active (session ${SID:-global}); the orchestrator must not edit source files directly. Spawn an implementer subagent for this change. Plan files under ~/.claude/plans/ (or \$CLAUDE_CONFIG_DIR/plans/ if set) and, within THIS project (${ROOT:-}), docs/plans/, docs/research/, and .gitattributes remain editable." >&2 exit 2 diff --git a/scripts/check.sh b/scripts/check.sh index 0d4d617..b2fa246 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,7 +13,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd -P )" MANIFEST="$SCRIPT_DIR/manifest.txt" CLAUDE_MD_MARKER='