vinta-ai-workflows 0.2.0-alpha2 → 0.2.0-alpha4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +5 -2
  3. package/package.json +7 -1
  4. package/schemas/README.md +1 -0
  5. package/schemas/ai-models.v1.schema.json +87 -0
  6. package/schemas/vinta-ai-workflows-config.v1.schema.json +20 -1
  7. package/skills/vinta-analyze-codebase/SKILL.md +1 -1
  8. package/skills/vinta-bootstrap-ai-tools/SKILL.md +19 -9
  9. package/skills/vinta-derive-skills/SKILL.md +98 -47
  10. package/skills/vinta-derive-skills/resources/foundation-skills/deslop-comments/SKILL.md +148 -0
  11. package/skills/vinta-derive-skills/resources/foundation-skills/plan-feature/SKILL.md +24 -27
  12. package/skills/vinta-derive-skills/resources/foundation-skills/plan-feature/resources/ai-models.yaml +98 -0
  13. package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/SKILL.md +89 -0
  14. package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/claude-worktree-write-guard.py +118 -0
  15. package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/gen-claude-sandbox-settings.sh +167 -0
  16. package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/sandbox-run.sh +106 -0
  17. package/skills/vinta-derive-skills/resources/foundation-skills/thermo-nuclear-code-quality-review/SKILL.md +129 -0
  18. package/skills/vinta-derive-skills/resources/plan-execution/README.md +42 -0
  19. package/skills/vinta-derive-skills/resources/{implement-plan-template-modular-substitutions.md → plan-execution/partials/commit-strategy/modular.md} +38 -70
  20. package/skills/vinta-derive-skills/resources/plan-execution/partials/commit-strategy/stacked.md +46 -0
  21. package/skills/vinta-derive-skills/resources/plan-execution/partials/implementer-prompt.md +78 -0
  22. package/skills/vinta-derive-skills/resources/plan-execution/partials/model-pick.md +19 -0
  23. package/skills/vinta-derive-skills/resources/plan-execution/partials/pr-context.md +61 -0
  24. package/skills/vinta-derive-skills/resources/plan-execution/partials/review-layers.md +50 -0
  25. package/skills/vinta-derive-skills/resources/plan-execution/partials/worktree-seam.md +68 -0
  26. package/skills/vinta-derive-skills/resources/{amend-plan-template.md → plan-execution/shell/amend-plan-template.md} +48 -42
  27. package/skills/vinta-derive-skills/resources/plan-execution/shell/implement-phase-template.md +47 -0
  28. package/skills/vinta-derive-skills/resources/plan-execution/shell/implement-plan-template.md +186 -0
  29. package/skills/vinta-derive-skills/resources/plan-execution/shell/integrate-phase-template.md +30 -0
  30. package/skills/vinta-derive-skills/resources/plan-execution/shell/review-phase-template.md +25 -0
  31. package/skills/vinta-derive-skills/resources/systematic-debugging-template.md +7 -3
  32. package/skills/vinta-update-project-skills/SKILL.md +1 -1
  33. package/skills/vinta-derive-skills/resources/implement-plan-template.md +0 -416
@@ -0,0 +1,98 @@
1
+ # yaml-language-server: $schema=https://github.com/vintasoftware/vinta-ai-workflows/schemas/ai-models.v1.schema.json
2
+ #
3
+ # Per-tier AI model recommendations consumed by the `plan-feature` skill's
4
+ # "AI model selection per phase" section. This file is the single source of
5
+ # truth for concrete model IDs — the SKILL.md body carries the *selection
6
+ # guidance* (when each tier applies, how to write the suggestion) but never
7
+ # hard-codes model IDs, so the prose never goes stale.
8
+ #
9
+ # `last_verified` is the date a maintainer (or the nightly `check-ai-models`
10
+ # job in vinta-ai-workflows) last confirmed every `id` below still exists.
11
+ # Tier *placement* is human/LLM judgement — the job flags drift and proposes
12
+ # edits, it does not blindly reassign tiers. See scripts/check-ai-models.mjs.
13
+ schema_version: 1
14
+ last_verified: 2026-06-22
15
+
16
+ # Drift is checked against a free, no-key community aggregator (models.dev,
17
+ # vendor-keyed with release dates + pricing), cross-checked against LiteLLM's
18
+ # price/context JSON. No vendor API keys are needed to detect staleness — the
19
+ # checker only needs network access. The vendors tracked are derived from the
20
+ # `models:` keys under `tiers` below (anthropic / openai / google), which must
21
+ # match the provider keys used by the aggregator.
22
+ source:
23
+ primary: https://models.dev/api.json
24
+ fallback: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
25
+
26
+ # Tiers run cheapest/fastest (1) → architectural/hard (4). Default to the
27
+ # cheapest tier that plausibly one-shots the work; a cheap model failing fast
28
+ # beats an expensive one succeeding slowly.
29
+ tiers:
30
+ - tier: 1
31
+ label: cheapest/fastest (boilerplate, exact-precedent edits)
32
+ use_for: >-
33
+ single migration adding column or index, exporting from __init__.py,
34
+ registering admin, scaffolding empty Django app, thin serializer
35
+ mirroring existing pattern verbatim.
36
+ models:
37
+ anthropic:
38
+ - id: claude-haiku-4-5
39
+ openai:
40
+ - id: gpt-5-nano
41
+ note: fall back to gpt-5-mini if file has any branching logic
42
+ google:
43
+ - id: gemini-2.5-flash-lite
44
+
45
+ - tier: 2
46
+ label: standard pattern application
47
+ use_for: >-
48
+ repository methods, DRF serializer with non-trivial validation, ViewSet
49
+ wiring with filterset, pytest unit/integration tests against established
50
+ fixtures, simple HStore/ArrayField additions.
51
+ models:
52
+ anthropic:
53
+ - id: claude-haiku-4-5
54
+ note: with iteration; step up to claude-sonnet-4-6 if phase touches >3 files
55
+ - id: claude-sonnet-4-6
56
+ note: step-up target when phase touches >3 files
57
+ openai:
58
+ - id: gpt-5-mini
59
+ google:
60
+ - id: gemini-2.5-flash
61
+
62
+ - tier: 3
63
+ label: multi-file orchestration, business logic, SQL views
64
+ use_for: >-
65
+ use case coordinating across repositories with non-trivial branching,
66
+ new vw_* view + non-managed model + migration, serializer with
67
+ cross-field validation affecting use-case behavior, integration tests
68
+ covering concurrency edges.
69
+ models:
70
+ anthropic:
71
+ - id: claude-sonnet-4-6
72
+ openai:
73
+ - id: gpt-5
74
+ google:
75
+ - id: gemini-2.5-pro
76
+ - id: gemini-3-pro
77
+ note: stronger code recall
78
+
79
+ - tier: 4
80
+ label: architectural / novel / hard
81
+ use_for: >-
82
+ cycle detection in user-mutable trees, transactional batch protocols
83
+ with deferred constraints, partitioned-to-partitioned FK design, perf
84
+ tuning slow query against partitioned hot table, debugging heisenbug.
85
+ models:
86
+ anthropic:
87
+ - id: claude-opus-4-7
88
+ note: "[1m] 1M-context variant for large codebases"
89
+ openai:
90
+ - id: gpt-5
91
+ note: with extended thinking
92
+ - id: o3
93
+ note: pure reasoning
94
+ - id: o4
95
+ note: pure reasoning
96
+ google:
97
+ - id: gemini-3-pro
98
+ note: with thinking
@@ -199,6 +199,73 @@ Quick walk-through of common gotchas. For each, follow the same fork/share patte
199
199
 
200
200
  If the **Plan inspection** step inferred the plan doesn't touch any of these: symlink / share. If unsure: fork. Cheap.
201
201
 
202
+ ## Step 5.5 — Filesystem sandbox (OS-level write guard)
203
+
204
+ Symlinking + threading the worktree path through prompts keeps *cooperative* agents in the worktree, but it's not a guarantee: a buggy agent (often a smaller model spawned for a phase) can resolve an absolute path back to the **main checkout** and silently write there. Those writes never reach the worktree's commit — they sit as uncommitted thrash in the main checkout, and the "missing" edits read as a silent agent failure. Prompt instructions don't stop it; they rely on the agent's goodwill.
205
+
206
+ The deterministic fix is to confine the *process* (not the tool) at the OS filesystem layer: make the main checkout read-only for any command run inside the sandbox, regardless of which agent CLI issues the writes. The bundled [scripts/sandbox-run.sh](scripts/sandbox-run.sh) does this — `sandbox-exec` on macOS, `bwrap` (bubblewrap) on Linux. **This process-wrapping model works for runtimes that spawn subagents as subprocesses** (`codex exec`, a `claude -p` child, a custom runner). Runtimes that run subagents **in-process** — notably claude-code's Task tool, where there's no child command to wrap — use the harness-config guard in [In-process runtimes (claude-code)](#55a--in-process-runtimes-claude-code) instead.
207
+
208
+ **Model: deny-main, allow-rest.** The whole filesystem stays writable EXCEPT the main checkout, with the worktree (and the shared `.vinta-ai-workflows` dir) punched back to writable — even though the worktree usually lives *under* the main checkout (`.claude/worktrees/<name>/`). This deliberately does **not** lock `HOME` / caches / `/tmp`, so package managers, build tools, and test runners behave exactly as unsandboxed. The only thing the cage blocks is a write to the main checkout's own tracked tree — which is exactly the bug.
209
+
210
+ Wrapper contract:
211
+
212
+ ```bash
213
+ sandbox-run.sh \
214
+ --deny <main-checkout-root> \
215
+ --allow <worktree-path> \
216
+ --allow <summary_dir-and-prs-context-parent> # the .vinta-ai-workflows dir
217
+ -- <command> [args...]
218
+ ```
219
+
220
+ The command runs with main-checkout writes blocked. A stray write fails with `Operation not permitted` (macOS) / `EROFS` (Linux) — the agent sees an ordinary error and retries against the correct path; the worktree stays the only writable copy of the repo.
221
+
222
+ **Capability probe (run here, record the result):**
223
+
224
+ ```bash
225
+ command -v sandbox-exec >/dev/null && tier=enforced # macOS
226
+ command -v bwrap >/dev/null && tier=enforced # Linux
227
+ # neither → tier=none (sandbox unavailable)
228
+ ```
229
+
230
+ - `tier=enforced` → the OS guarantee holds. The caller ([implement-plan](../implement-plan/SKILL.md)) can downgrade its post-run stray-write check to a backstop.
231
+ - `tier=none` → no sandbox tool on this machine (locked-down CI without `bwrap`'s user-namespace support, Windows, etc.). `sandbox-run.sh` still runs the command (best-effort, unsandboxed) and prints a loud warning; prevention falls back to the caller's reactive stray-write detection. **Don't fail provisioning over this** — surface the degraded tier and continue.
232
+
233
+ Escape hatch: `VINTA_SANDBOX=off` makes the wrapper a transparent pass-through (for the rare tool that needs access the sandbox blocks). Record the achieved tier in the summary (next step) so the caller knows whether it's running guaranteed or best-effort.
234
+
235
+ **Sandbox-mode shared-state rule.** Because main is read-only inside the cage, any path the *run* writes must live in the worktree or in an `--allow`'d dir — never a symlink that resolves back into the main checkout. Two follow-ons:
236
+ - The existing `deps_change=true` → copy/reinstall logic already gives the worktree its own writable `node_modules/` when a phase installs deps; non-churn phases keep the read-only symlink (agents shouldn't write deps anyway — correct).
237
+ - The `.vinta-ai-workflows/` dir (summary YAMLs, prs-context) is shared/symlinked but **written** during a run, so it must be passed as an `--allow` path (or made a real writable dir in the worktree). Record which.
238
+
239
+ ### 5.5a — In-process runtimes (claude-code)
240
+
241
+ `sandbox-run.sh` confines a **process**: it wraps the command that launches a subagent. That only works when the runtime spawns subagents as **subprocesses** (`codex exec …`, a `claude -p …` child, a custom runner) — you wrap that argv and the kernel blocks the child's main-checkout writes.
242
+
243
+ **Claude Code runs subagents in-process.** Its Task tool shares the orchestrator's OS process and tool pipeline — there's no child launch command to wrap. The guard has to live in the harness's own config instead. Two complementary layers, both bundled here, both matching the same deny-main/allow-rest model:
244
+
245
+ - **Layer A — `PreToolUse` write-guard hook** ([scripts/claude-worktree-write-guard.py](scripts/claude-worktree-write-guard.py)). A session-level `PreToolUse` hook fires for **every** tool call in the session — orchestrator **and** every in-process Task subagent — because they run through the same pipeline. This hook matches the file-editing tools (`Edit|Write|MultiEdit|NotebookEdit`), realpath-resolves the target, and blocks (exit 2 → the model gets the reason and retries) any write that escapes the worktree into the main checkout. Filesystem-only, **no network impact**. This is the load-bearing claude-code guard and the concrete form of implement-plan's "runtime pre-write guard hook" option.
246
+
247
+ - **Layer B — Claude Code's native OS sandbox** (opt-in). Claude Code has adopted the same OS model `sandbox-run.sh` implements (`sandbox-exec` / `bwrap`), configured via `sandbox.filesystem.denyWrite` / `allowWrite` in `settings.json` instead of a wrapper, and it applies to **Bash run inside subagents** too. Turning it on closes the one gap Layer A leaves — a write issued through **Bash** (`echo > <main>/f`), which a file-tool hook can't see. **Caveat: enabling the native sandbox also turns on network isolation**, so `pnpm install` / `git push` break unless you allow-list the registry + git-remote hosts. Without Layer B, Bash-issued stray writes fall back to implement-plan's post-run `git -C <main> status` backstop (Layer 1).
248
+
249
+ Generate both with [scripts/gen-claude-sandbox-settings.sh](scripts/gen-claude-sandbox-settings.sh):
250
+
251
+ ```bash
252
+ # Layer A only (filesystem-only, no network config needed):
253
+ scripts/gen-claude-sandbox-settings.sh --worktree <worktree-path> --main <main-checkout-root>
254
+
255
+ # Layer A + B (also OS-block Bash writes; must allow-list network hosts):
256
+ scripts/gen-claude-sandbox-settings.sh --worktree <worktree-path> --main <main-checkout-root> \
257
+ --os-sandbox --allow-domain <registry-host> --allow-domain <git-remote-host>
258
+ ```
259
+
260
+ It writes `<worktree>/.claude/settings.json` (merging, not clobbering) with `<worktree>`, `<main>/.git` (git worktrees commit into the main repo's `.git` — **must** stay writable), and `.vinta-ai-workflows` punched back to writable.
261
+
262
+ **Two deployment models — settings are read at session start:**
263
+
264
+ 1. **Worktree-rooted session (recommended).** Run the plan from inside the worktree (`cd <worktree> && claude`). The generated `<worktree>/.claude/settings.json` governs that session and all its Task subagents. As a bonus, claude-code's default working-directory boundary already discourages file writes above cwd, so this is belt-and-suspenders. Provision the worktree + settings **before** starting the plan session.
265
+ 2. **Config in the invocation's project root.** If the orchestrator already runs in the main checkout (implement-plan's default topology), the hook + sandbox must instead live in the main checkout's `settings.json` / `settings.local.json` **before** that session starts — the native `sandbox` block in particular is read at launch and won't retroactively cage an already-running session. Provision first, then start the plan; don't expect a mid-run `use_worktree` opt-in to enable the OS sandbox for the session that requested it.
266
+
267
+ Record the achieved tier the same way as the subprocess path: `enforced` when Layer B is active (or Layer A alone if the team accepts hook-only), `none` when neither is wired.
268
+
202
269
  ## Step 6 — Write the summary file
203
270
 
204
271
  `.vinta-ai-workflows/worktrees/<name>.yaml` (committed to `.gitignore` via the existing `.vinta-ai-workflows/` umbrella). Schema:
@@ -240,6 +307,15 @@ state:
240
307
  redis_db_index: <int>
241
308
  s3_prefix: wt-<name>/
242
309
  cron_disabled: <bool>
310
+ sandbox:
311
+ tier: enforced | none # from the Filesystem sandbox step's capability probe
312
+ launcher: sandbox-exec | bwrap | claude-hook | claude-native-sandbox | null
313
+ # sandbox-exec / bwrap → subprocess runtimes via scripts/sandbox-run.sh
314
+ # claude-hook → in-process (claude-code) Layer A hook only (file tools)
315
+ # claude-native-sandbox → in-process (claude-code) Layer A hook + Layer B OS sandbox
316
+ # null → tier=none (no guard wired)
317
+ deny: [<main-checkout-root>] # subtree(s) made read-only inside the cage
318
+ allow: [<worktree-path>, <main-checkout-root>/.git, <.vinta-ai-workflows-dir>] # writable exceptions
243
319
  notes: |
244
320
  <freeform — anything the user / agent should know>
245
321
  ```
@@ -254,6 +330,12 @@ Branch: `<branch>` (based on `<base-ref>`).
254
330
  ## What's forked vs shared
255
331
  <one-line per row in `state` above>
256
332
 
333
+ ## Write protection
334
+ Sandbox tier: `<enforced | none>` (launcher: `<sandbox-exec | bwrap | none>`).
335
+ When `enforced`, commands run via `sandbox-run.sh` cannot write to the main
336
+ checkout — only this worktree (+ `.vinta-ai-workflows`). When `none`, no OS
337
+ guard is active; the orchestrator's post-run stray-write check is the backstop.
338
+
257
339
  ## How to run things
258
340
  - Lint: `<project lint command>` (runs inside this worktree)
259
341
  - Tests: `<project test command>` (against `<test_db.forked_name>`)
@@ -276,6 +358,7 @@ One paragraph to the caller:
276
358
 
277
359
  - Worktree path + branch.
278
360
  - One line per fork decision (deps / env / dev DB / test DB / compose / other).
361
+ - **Sandbox tier** (`enforced` via `sandbox-exec` / `bwrap`, or `none`) — so the caller knows whether main-checkout writes are OS-blocked or only caught after the fact.
279
362
  - Anything the user must do manually before running the app (`source .envrc`, `direnv allow`, login to a cloud CLI in the worktree, etc.).
280
363
  - Teardown command.
281
364
 
@@ -300,6 +383,9 @@ Every step gated on user confirmation when the worktree has un-pushed branches.
300
383
  - **Every fork decision lands in `.vinta-ai-workflows/worktrees/<name>.yaml`.** Teardown reads it; humans grep it; agents resuming a stalled plan read it. No decision lives only in conversation memory.
301
384
  - **Worktree root governed by runtime conventions.** claude-code uses `.claude/worktrees/`; other harnesses use sibling dirs. Don't fight the harness — match it.
302
385
  - **Don't mutate the main checkout from this skill.** Every write goes to the worktree or to `.vinta-ai-workflows/`. Forking a Postgres DB is the one exception (the new DB lives in the same server) — document it loudly in the summary.
386
+ - **Sandbox is the deterministic guard; the prompt is not.** When a sandbox tool exists, the [Filesystem sandbox](#step-55--filesystem-sandbox-os-level-write-guard) step's `sandbox-run.sh` makes the main checkout read-only for any spawned command — this is what actually *prevents* stray main-checkout writes for subprocess runtimes. For **in-process** runtimes (claude-code's Task tool) there's no subprocess to wrap: wire the `PreToolUse` hook (+ optional native OS sandbox) from [In-process runtimes (claude-code)](#55a--in-process-runtimes-claude-code) into the session's `settings.json` instead. Telling the agent "stay in the worktree" is best-effort only either way. Always probe + record the tier; never claim prevention when `tier=none`.
387
+ - **`<main>/.git` stays writable in every model.** Git worktrees store their commits in the main repo's `.git`; a deny that also blocks `<main>/.git` breaks every commit made inside the worktree. `sandbox-run.sh` callers and the generated claude-code settings must both `--allow` / `allowWrite` `<main>/.git`.
388
+ - **Deny-main, allow-rest — never allow-worktree-only.** Locking everything except the worktree breaks package managers / caches / `$HOME`. Lock only the main checkout subtree; re-allow the worktree (nested under it) and the written `.vinta-ai-workflows` dir. Don't tighten further without testing the project's real lint / test / build / migrate commands inside the cage.
303
389
  - **Worktree base = `origin/<default-branch>` by default.** `HEAD` only when the user explicitly confirms; record the choice in the summary.
304
390
  - **Refuse to provision a second worktree for the same branch.** Git enforces this — don't try to work around it.
305
391
  - **Don't auto-install heavy deps** (e.g. `pnpm install` from scratch) without confirmation when the project's main `node_modules/` is already populated — symlink first, ask if reinstall is needed.
@@ -311,6 +397,9 @@ Every step gated on user confirmation when the worktree has un-pushed branches.
311
397
  - **Forgetting `COMPOSE_PROJECT_NAME`.** Containers from worktree-A overwrite worktree-B's containers; volumes get nuked. Set it in `.env` so every `docker compose` call inherits.
312
398
  - **Sharing a Redis DB without per-worktree prefix.** Tests writing `user:123` collide across worktrees. Pick an index OR a key prefix.
313
399
  - **Copying `node_modules` for yarn PnP / absolute-path setups.** The copy carries baked-in paths from the main checkout. Reinstall instead — pnpm's relative-symlink store is the safe-to-copy exception.
400
+ - **Forgetting to `--allow` the `.vinta-ai-workflows` dir under sandbox.** It's shared/symlinked but the run *writes* it (summary YAMLs, prs-context). If it's not an `--allow` exception, those writes hit the read-only main subtree and fail mid-run. Pass it alongside the worktree path.
401
+ - **Assuming the sandbox is always there.** `bwrap` needs user namespaces enabled — some hardened kernels and CI images disable them. Probe (`command -v bwrap`); on `tier=none` degrade to the post-run check, don't silently believe writes are blocked.
402
+ - **Over-tightening into an allow-worktree-only cage.** Tempting, but it breaks every tool that writes outside the repo (npm cache, `~/.config`, `$TMPDIR`). The deny-main model is the one that needs no per-stack allowlist tuning.
314
403
  - **Leaving cron / background workers on in the worktree.** They poll the shared DB and double-process jobs. Default to off.
315
404
  - **Provisioning a worktree, running migrations, then realizing the user wanted to share the DB.** The **Database fork** step asks BEFORE migrating; rollback of a forked-DB migration is mechanical (drop the DB), but rollback of a shared-DB migration is a half-day.
316
405
  - **Symlinking `.env` and then editing it.** The edit leaks into main. Copy (not symlink) the moment `env_change = true`.
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """claude-worktree-write-guard.py — Claude Code PreToolUse hook that blocks
3
+ file-tool writes escaping a worktree into the protected main checkout.
4
+
5
+ Why this exists: Claude Code runs sub-agents (the Task tool) IN-PROCESS — they
6
+ share the orchestrator's OS process and tool pipeline, so there is no child
7
+ launch command to wrap with an OS sandbox (that model only works on harnesses
8
+ that spawn sub-agents as subprocesses, e.g. `codex exec`). A session-level
9
+ PreToolUse hook, by contrast, fires for every tool call in the session —
10
+ orchestrator AND Task sub-agents alike — because they run through the same
11
+ pipeline. This hook is the in-process equivalent of prepare-worktree's
12
+ `sandbox-run.sh` deny-main/allow-rest guard, scoped to the file-editing tools.
13
+
14
+ Scope: the file-write tools only — Edit, Write, MultiEdit, NotebookEdit. Bash
15
+ writes are NOT covered here (a hook can't reliably know every path a shell
16
+ command touches); use Claude Code's native `sandbox` (see
17
+ gen-claude-sandbox-settings.sh `--os-sandbox`) for OS-level Bash confinement,
18
+ and rely on implement-plan's post-run `git -C <main> status` backstop otherwise.
19
+
20
+ Model — deny-main, allow-rest (mirrors sandbox-run.sh): every path is allowed
21
+ EXCEPT ones that resolve under a --deny root, and an --allow root punches a
22
+ subtree back to writable even when nested under a --deny root. The worktree
23
+ usually lives UNDER the main checkout (.claude/worktrees/<name>/), and git
24
+ worktrees write commits into <main>/.git, so both the worktree and <main>/.git
25
+ are passed as --allow. Symlinks are resolved (realpath) before the check, so a
26
+ write through a symlink that lands back in the main checkout (e.g. a shared
27
+ node_modules) is correctly caught.
28
+
29
+ Usage (wired by gen-claude-sandbox-settings.sh into settings.json hooks):
30
+ claude-worktree-write-guard.py \
31
+ --deny <main-checkout-root> \
32
+ --allow <worktree-path> \
33
+ --allow <main-checkout-root>/.git \
34
+ --allow <main-checkout-root>/.vinta-ai-workflows
35
+
36
+ Reads the PreToolUse event JSON on stdin. Blocks by exiting 2 with a reason on
37
+ stderr — Claude Code feeds that stderr back to the model as the denial reason,
38
+ so the agent retries against the correct worktree path. Any non-write tool, or
39
+ a write that stays inside an --allow subtree, exits 0 (allowed). The guard fails
40
+ OPEN (exit 0) on malformed input or its own errors — it is a safety net layered
41
+ under the OS sandbox and the post-run backstop, never the sole gate, and must
42
+ never wedge a run by denying on a parsing hiccup.
43
+ """
44
+ import json
45
+ import os
46
+ import sys
47
+
48
+ # Tool-input keys that carry a write target, per tool.
49
+ PATH_KEYS = ("file_path", "notebook_path")
50
+ GUARDED_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
51
+
52
+
53
+ def canon(path):
54
+ """Absolute, symlink-resolved path. realpath resolves a shared node_modules
55
+ / .vinta-ai-workflows symlink back to its real location so the prefix check
56
+ reflects where the byte actually lands, not the symlink's apparent path."""
57
+ return os.path.realpath(os.path.abspath(path))
58
+
59
+
60
+ def under(path, root):
61
+ """True if `path` is `root` or nested beneath it. Compares canonical paths
62
+ with a trailing separator so /main-evil is not treated as under /main."""
63
+ path, root = canon(path), canon(root)
64
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
65
+
66
+
67
+ def parse_args(argv):
68
+ deny, allow = [], []
69
+ i = 0
70
+ while i < len(argv):
71
+ if argv[i] == "--deny" and i + 1 < len(argv):
72
+ deny.append(argv[i + 1]); i += 2
73
+ elif argv[i] == "--allow" and i + 1 < len(argv):
74
+ allow.append(argv[i + 1]); i += 2
75
+ else:
76
+ i += 1
77
+ return deny, allow
78
+
79
+
80
+ def main():
81
+ deny, allow = parse_args(sys.argv[1:])
82
+ if not deny:
83
+ # Nothing to protect — nothing to do.
84
+ return 0
85
+
86
+ try:
87
+ event = json.load(sys.stdin)
88
+ except Exception:
89
+ return 0 # fail open — never wedge a run on a parse error
90
+
91
+ if event.get("tool_name") not in GUARDED_TOOLS:
92
+ return 0
93
+
94
+ tool_input = event.get("tool_input") or {}
95
+ target = next((tool_input[k] for k in PATH_KEYS if tool_input.get(k)), None)
96
+ if not target:
97
+ return 0 # no write target we recognize
98
+
99
+ # allow wins over deny (the worktree + .git + .vinta-ai-workflows are nested
100
+ # under the denied main root and must stay writable).
101
+ if any(under(target, a) for a in allow):
102
+ return 0
103
+ if any(under(target, d) for d in deny):
104
+ resolved = canon(target)
105
+ wt = allow[0] if allow else "the worktree"
106
+ sys.stderr.write(
107
+ f"Blocked write to the main checkout: {resolved}\n"
108
+ f"This file is outside the worktree. Write to the worktree instead "
109
+ f"(under {wt}). If you meant to edit a shared file, it is protected "
110
+ f"for the duration of this plan run.\n"
111
+ )
112
+ return 2 # exit 2 → Claude Code blocks the call, stderr is the reason
113
+
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ # gen-claude-sandbox-settings.sh — generate a Claude Code settings.json that
3
+ # confines a worktree session (orchestrator + its in-process Task sub-agents)
4
+ # to writing inside the worktree, blocking stray writes to the main checkout.
5
+ #
6
+ # Claude Code runs sub-agents in-process (the Task tool shares the orchestrator's
7
+ # OS process), so the subprocess-wrapping model of sandbox-run.sh does not apply
8
+ # — the guard has to live in the harness's own settings. This script wires two
9
+ # complementary layers, matching prepare-worktree's deny-main/allow-rest model:
10
+ #
11
+ # Layer A (always) — a PreToolUse hook (claude-worktree-write-guard.py) that
12
+ # blocks the file-editing tools (Edit/Write/MultiEdit/NotebookEdit) from
13
+ # writing outside the worktree. Filesystem-only; no network impact. Fires
14
+ # for the orchestrator AND every Task sub-agent (shared session pipeline).
15
+ #
16
+ # Layer B (opt-in, --os-sandbox) — Claude Code's NATIVE OS sandbox
17
+ # (`sandbox.filesystem.denyWrite`/`allowWrite`, Seatbelt on macOS / bwrap on
18
+ # Linux) so Bash-issued writes are blocked at the kernel too. NOTE: enabling
19
+ # the native sandbox also turns on NETWORK isolation — dep installs and
20
+ # `git push` break unless you allow-list registries/remotes. Pass the hosts
21
+ # the plan needs via --allow-domain (repeatable). Without --os-sandbox, Bash
22
+ # writes fall back to implement-plan's post-run `git -C <main> status` check.
23
+ #
24
+ # Both layers keep <worktree>, <main>/.git (git worktrees commit into the main
25
+ # repo's .git), and the shared .vinta-ai-workflows dir writable.
26
+ #
27
+ # Usage:
28
+ # gen-claude-sandbox-settings.sh \
29
+ # --worktree <worktree-path> \
30
+ # --main <main-checkout-root> \
31
+ # [--allow <extra-writable-path>]... # e.g. .vinta-ai-workflows if elsewhere
32
+ # [--out <settings-file>] # default: <worktree>/.claude/settings.json
33
+ # [--os-sandbox] # also emit the native sandbox block
34
+ # [--allow-domain <host>]... # network hosts to allow under --os-sandbox
35
+ #
36
+ # The generated file is meant to govern a Claude Code session ROOTED IN THE
37
+ # WORKTREE (`cd <worktree> && claude`), the recommended claude-code topology for
38
+ # an isolated plan run — the settings are read at session start. See Step 5.5 of
39
+ # ../SKILL.md for the two deployment models and their timing caveats.
40
+ #
41
+ # Exit: 0 on success; 2 on argument/setup errors.
42
+
43
+ set -u
44
+
45
+ die() { echo "gen-claude-sandbox-settings: $*" >&2; exit 2; }
46
+
47
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
48
+ GUARD="$SCRIPT_DIR/claude-worktree-write-guard.py"
49
+
50
+ WORKTREE=""; MAIN=""; OUT=""; OS_SANDBOX=0
51
+ EXTRA_ALLOW=(); DOMAINS=()
52
+ while [ $# -gt 0 ]; do
53
+ case "$1" in
54
+ --worktree) shift; [ $# -gt 0 ] || die "--worktree needs a path"; WORKTREE="$1"; shift ;;
55
+ --main) shift; [ $# -gt 0 ] || die "--main needs a path"; MAIN="$1"; shift ;;
56
+ --out) shift; [ $# -gt 0 ] || die "--out needs a path"; OUT="$1"; shift ;;
57
+ --allow) shift; [ $# -gt 0 ] || die "--allow needs a path"; EXTRA_ALLOW+=("$1"); shift ;;
58
+ --allow-domain) shift; [ $# -gt 0 ] || die "--allow-domain needs a host"; DOMAINS+=("$1"); shift ;;
59
+ --os-sandbox) OS_SANDBOX=1; shift ;;
60
+ *) die "unknown arg: $1" ;;
61
+ esac
62
+ done
63
+
64
+ [ -n "$WORKTREE" ] || die "--worktree <path> required"
65
+ [ -n "$MAIN" ] || die "--main <path> required"
66
+ [ -f "$GUARD" ] || die "guard hook not found next to this script: $GUARD"
67
+
68
+ # Canonicalize (leaf need not exist).
69
+ canon() {
70
+ local p="$1" d b
71
+ if [ -d "$p" ]; then (cd "$p" && pwd -P); return; fi
72
+ d=$(dirname "$p"); b=$(basename "$p")
73
+ if [ -d "$d" ]; then echo "$(cd "$d" && pwd -P)/$b"; else echo "$p"; fi
74
+ }
75
+
76
+ WORKTREE=$(canon "$WORKTREE")
77
+ MAIN=$(canon "$MAIN")
78
+ [ -d "$WORKTREE" ] || die "worktree path is not a directory: $WORKTREE"
79
+ [ -d "$MAIN" ] || die "main checkout path is not a directory: $MAIN"
80
+ [ -z "$OUT" ] && OUT="$WORKTREE/.claude/settings.json"
81
+
82
+ # Writable exceptions punched back through the main-checkout deny: the worktree,
83
+ # the shared git database (worktree commits land in <main>/.git), the shared
84
+ # .vinta-ai-workflows dir, plus any caller extras.
85
+ ALLOW=("$WORKTREE" "$MAIN/.git" "$MAIN/.vinta-ai-workflows")
86
+ for p in "${EXTRA_ALLOW[@]:-}"; do [ -n "$p" ] && ALLOW+=("$(canon "$p")"); done
87
+
88
+ if [ "$OS_SANDBOX" -eq 1 ] && [ "${#DOMAINS[@]}" -eq 0 ]; then
89
+ echo "gen-claude-sandbox-settings: WARNING --os-sandbox with no --allow-domain — network" >&2
90
+ echo " isolation is ON, so dep installs / git push over the network will fail. Pass the" >&2
91
+ echo " registry + git remote hosts via --allow-domain, or drop --os-sandbox for hook-only." >&2
92
+ fi
93
+
94
+ mkdir -p "$(dirname "$OUT")" || die "cannot create settings dir for $OUT"
95
+
96
+ # Emit JSON via python3 (safe escaping of paths); merge into an existing file if
97
+ # one is present so we don't clobber unrelated settings the worktree may carry.
98
+ GUARD="$GUARD" OUT="$OUT" MAIN="$MAIN" WORKTREE="$WORKTREE" OS_SANDBOX="$OS_SANDBOX" \
99
+ python3 - "${ALLOW[@]}" -- "${DOMAINS[@]:-}" <<'PY' || die "failed to write settings"
100
+ import json, os, sys
101
+
102
+ guard, out, main, worktree = (os.environ[k] for k in ("GUARD", "OUT", "MAIN", "WORKTREE"))
103
+ os_sandbox = os.environ["OS_SANDBOX"] == "1"
104
+
105
+ sep = sys.argv.index("--")
106
+ allow = [a for a in sys.argv[1:sep] if a]
107
+ domains = [d for d in sys.argv[sep + 1:] if d]
108
+
109
+ # Layer A — PreToolUse write-guard hook (always).
110
+ guard_cmd = " ".join(
111
+ [json.dumps(guard) if " " in guard else guard, "--deny", main]
112
+ + sum((["--allow", a] for a in allow), [])
113
+ )
114
+ hook_entry = {
115
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
116
+ "hooks": [{"type": "command", "command": guard_cmd}],
117
+ }
118
+
119
+ # Merge into an existing settings file if present (preserve unrelated keys).
120
+ settings = {}
121
+ if os.path.exists(out):
122
+ try:
123
+ with open(out) as f:
124
+ settings = json.load(f) or {}
125
+ except Exception:
126
+ settings = {}
127
+
128
+ hooks = settings.setdefault("hooks", {})
129
+ pre = hooks.setdefault("PreToolUse", [])
130
+ # Drop any prior guard entry (idempotent re-runs) then append the fresh one.
131
+ pre = [e for e in pre if "claude-worktree-write-guard" not in json.dumps(e)]
132
+ pre.append(hook_entry)
133
+ hooks["PreToolUse"] = pre
134
+
135
+ # Layer B — native OS sandbox (opt-in).
136
+ if os_sandbox:
137
+ settings["sandbox"] = {
138
+ "enabled": True,
139
+ # Degrade to unsandboxed on platforms without Seatbelt/bwrap rather than
140
+ # hard-failing the run; the hook (Layer A) still guards file writes.
141
+ "failIfUnavailable": False,
142
+ "filesystem": {
143
+ "denyWrite": [main],
144
+ "allowWrite": allow,
145
+ },
146
+ "network": {
147
+ # Sandbox forces network isolation; without an allow-list, installs
148
+ # and pushes break. Callers pass registry + remote hosts.
149
+ "allowedDomains": domains,
150
+ },
151
+ }
152
+
153
+ with open(out, "w") as f:
154
+ json.dump(settings, f, indent=2)
155
+ f.write("\n")
156
+
157
+ print(f"wrote {out}")
158
+ print(f" layer A (hook): guard on Edit|Write|MultiEdit|NotebookEdit, deny {main}")
159
+ print(f" allow-write: {', '.join(allow)}")
160
+ if os_sandbox:
161
+ dom = ", ".join(domains) if domains else "(none — network will be blocked)"
162
+ print(f" layer B (os sandbox): enabled, network allowedDomains: {dom}")
163
+ else:
164
+ print(" layer B (os sandbox): OFF (hook-only; Bash writes rely on the post-run backstop)")
165
+ PY
166
+
167
+ echo "gen-claude-sandbox-settings: done."
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # sandbox-run.sh — run a command with writes to a protected subtree (the main
3
+ # checkout) blocked at the OS level, regardless of which agent harness issues
4
+ # the writes. Harness-agnostic: it confines the *process*, not the tool.
5
+ #
6
+ # Model: deny-main, allow-rest. The whole filesystem stays writable EXCEPT the
7
+ # --deny subtree(s), with --allow subtree(s) punched back to writable (so a
8
+ # worktree nested under the main checkout, and the shared .vinta-ai-workflows
9
+ # dir, keep working). This deliberately does NOT lock HOME / caches / tmp, so
10
+ # package managers, build tools, and test runners behave exactly as unsandboxed.
11
+ #
12
+ # Usage:
13
+ # sandbox-run.sh --deny <path> [--deny <path>...] \
14
+ # [--allow <path>...] -- <command> [args...]
15
+ #
16
+ # Required:
17
+ # --deny <path> subtree to make read-only (the main checkout root). Repeatable.
18
+ # Optional:
19
+ # --allow <path> subtree to keep writable even if nested under a --deny path
20
+ # (the worktree; the .vinta-ai-workflows summary/prs-context dir).
21
+ # Repeatable.
22
+ #
23
+ # Environment:
24
+ # VINTA_SANDBOX=off bypass entirely — exec the command unsandboxed and
25
+ # print a warning (escape hatch when a tool needs
26
+ # access the sandbox blocks, or on an unsupported FS).
27
+ # VINTA_SANDBOX_TIER_FILE if set, write the achieved tier to this file:
28
+ # "enforced" (sandbox active) or "none" (no sandbox
29
+ # tool available / bypassed). Callers read it to know
30
+ # whether the OS guarantee held or a backstop is needed.
31
+ #
32
+ # Exit: the command's own exit code. Setup failures exit 2.
33
+
34
+ set -u
35
+
36
+ die() { echo "sandbox-run: $*" >&2; exit 2; }
37
+
38
+ DENY=()
39
+ ALLOW=()
40
+ CMD=()
41
+ parsing_cmd=0
42
+ while [ $# -gt 0 ]; do
43
+ if [ "$parsing_cmd" -eq 1 ]; then CMD+=("$1"); shift; continue; fi
44
+ case "$1" in
45
+ --deny) shift; [ $# -gt 0 ] || die "--deny needs a path"; DENY+=("$1"); shift ;;
46
+ --allow) shift; [ $# -gt 0 ] || die "--allow needs a path"; ALLOW+=("$1"); shift ;;
47
+ --) parsing_cmd=1; shift ;;
48
+ *) die "unknown arg: $1 (did you forget '--' before the command?)" ;;
49
+ esac
50
+ done
51
+
52
+ [ "${#CMD[@]}" -gt 0 ] || die "no command given after '--'"
53
+ [ "${#DENY[@]}" -gt 0 ] || die "at least one --deny <path> required"
54
+
55
+ # Canonicalize a path without requiring the leaf to exist.
56
+ canon() {
57
+ local p="$1" d b
58
+ if [ -d "$p" ]; then (cd "$p" && pwd -P); return; fi
59
+ d=$(dirname "$p"); b=$(basename "$p")
60
+ if [ -d "$d" ]; then echo "$(cd "$d" && pwd -P)/$b"; else echo "$p"; fi
61
+ }
62
+
63
+ DENY_C=(); for p in "${DENY[@]}"; do DENY_C+=("$(canon "$p")"); done
64
+ ALLOW_C=(); for p in "${ALLOW[@]}"; do ALLOW_C+=("$(canon "$p")"); done
65
+
66
+ record_tier() {
67
+ [ -n "${VINTA_SANDBOX_TIER_FILE:-}" ] && printf '%s' "$1" > "$VINTA_SANDBOX_TIER_FILE" 2>/dev/null || true
68
+ }
69
+
70
+ if [ "${VINTA_SANDBOX:-}" = "off" ]; then
71
+ echo "sandbox-run: VINTA_SANDBOX=off — running UNSANDBOXED (no main-checkout protection)" >&2
72
+ record_tier none
73
+ exec "${CMD[@]}"
74
+ fi
75
+
76
+ # ---- macOS: sandbox-exec -----------------------------------------------------
77
+ if command -v sandbox-exec >/dev/null 2>&1; then
78
+ # Explicit XXXXXX template (portable across BSD mktemp on stock macOS and GNU
79
+ # mktemp from coreutils — `mktemp -t prefix` is not portable between them).
80
+ profile=$(mktemp "${TMPDIR:-/tmp}/vinta-sandbox.XXXXXX") || die "mktemp failed"
81
+ trap 'rm -f "$profile"' EXIT
82
+ {
83
+ echo '(version 1)'
84
+ echo '(allow default)'
85
+ for p in "${DENY_C[@]}"; do echo "(deny file-write* (subpath \"$p\"))"; done
86
+ for p in "${ALLOW_C[@]}"; do echo "(allow file-write* (subpath \"$p\"))"; done
87
+ } > "$profile"
88
+ record_tier enforced
89
+ exec sandbox-exec -f "$profile" "${CMD[@]}"
90
+ fi
91
+
92
+ # ---- Linux: bubblewrap -------------------------------------------------------
93
+ if command -v bwrap >/dev/null 2>&1; then
94
+ args=(--bind / /)
95
+ for p in "${DENY_C[@]}"; do args+=(--ro-bind "$p" "$p"); done # main → read-only
96
+ for p in "${ALLOW_C[@]}"; do args+=(--bind "$p" "$p"); done # worktree → writable (overrides ro)
97
+ args+=(--dev /dev --proc /proc)
98
+ record_tier enforced
99
+ exec bwrap "${args[@]}" -- "${CMD[@]}"
100
+ fi
101
+
102
+ # ---- No sandbox tool: best-effort fallback -----------------------------------
103
+ echo "sandbox-run: no sandbox tool (sandbox-exec / bwrap) found — running UNSANDBOXED." >&2
104
+ echo "sandbox-run: main-checkout writes are NOT blocked; rely on the post-run stray-write check." >&2
105
+ record_tier none
106
+ exec "${CMD[@]}"