vinta-ai-workflows 0.2.0-alpha3 → 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.
- package/CHANGELOG.md +124 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/schemas/vinta-ai-workflows-config.v1.schema.json +15 -0
- package/skills/vinta-analyze-codebase/SKILL.md +1 -1
- package/skills/vinta-bootstrap-ai-tools/SKILL.md +18 -8
- package/skills/vinta-derive-skills/SKILL.md +84 -39
- package/skills/vinta-derive-skills/resources/foundation-skills/deslop-comments/SKILL.md +148 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/SKILL.md +39 -4
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/claude-worktree-write-guard.py +118 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/gen-claude-sandbox-settings.sh +167 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/prepare-worktree/scripts/sandbox-run.sh +3 -1
- package/skills/vinta-derive-skills/resources/foundation-skills/thermo-nuclear-code-quality-review/SKILL.md +129 -0
- package/skills/vinta-derive-skills/resources/plan-execution/README.md +42 -0
- package/skills/vinta-derive-skills/resources/{implement-plan-template-modular-substitutions.md → plan-execution/partials/commit-strategy/modular.md} +38 -70
- package/skills/vinta-derive-skills/resources/plan-execution/partials/commit-strategy/stacked.md +46 -0
- package/skills/vinta-derive-skills/resources/plan-execution/partials/implementer-prompt.md +78 -0
- package/skills/vinta-derive-skills/resources/plan-execution/partials/model-pick.md +19 -0
- package/skills/vinta-derive-skills/resources/plan-execution/partials/pr-context.md +61 -0
- package/skills/vinta-derive-skills/resources/plan-execution/partials/review-layers.md +50 -0
- package/skills/vinta-derive-skills/resources/plan-execution/partials/worktree-seam.md +68 -0
- package/skills/vinta-derive-skills/resources/{amend-plan-template.md → plan-execution/shell/amend-plan-template.md} +48 -42
- package/skills/vinta-derive-skills/resources/plan-execution/shell/implement-phase-template.md +47 -0
- package/skills/vinta-derive-skills/resources/plan-execution/shell/implement-plan-template.md +186 -0
- package/skills/vinta-derive-skills/resources/plan-execution/shell/integrate-phase-template.md +30 -0
- package/skills/vinta-derive-skills/resources/plan-execution/shell/review-phase-template.md +25 -0
- package/skills/vinta-derive-skills/resources/systematic-debugging-template.md +7 -3
- package/skills/vinta-update-project-skills/SKILL.md +1 -1
- package/skills/vinta-derive-skills/resources/implement-plan-template.md +0 -436
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: deslop-comments
|
|
3
|
+
description: Rewrite code comments and docs touched during the current conversation into Simple English, stripping AI-slop / AI-lingo vocabulary and negative framing. Use when the user says "deslop these comments", "clean up the comments we just wrote", "rewrite this in plain English", or after a review flags comments as convoluted/AI-sounding. Comment-only - never changes function names, APIs, or behavior.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deslop comments
|
|
7
|
+
|
|
8
|
+
Rewrites comments and doc blocks into short, direct Simple English. This is a comment-only pass: no renames, no logic changes, no behavior changes.
|
|
9
|
+
|
|
10
|
+
## Scope
|
|
11
|
+
|
|
12
|
+
Default scope is **files the current task actually created or edited** - find the candidate set with `git diff --name-only` / `git status --short` against the base the conversation started from. If the user names specific files, a directory, or a Pull Request instead, use that scope.
|
|
13
|
+
|
|
14
|
+
Do not expand scope to a file just because the agent `Read` it or reviewed it in passing - a debugging or review session routinely reads many unrelated files, and reading one is not authorization to rewrite its comments. If there is no edited-file set and no explicit scope from the user, ask which files to cover.
|
|
15
|
+
|
|
16
|
+
## What counts as slop
|
|
17
|
+
|
|
18
|
+
Rewrite a comment if it has any of:
|
|
19
|
+
|
|
20
|
+
1. **Dense, multi-clause sentences** that try to explain everything at once instead of one idea per sentence.
|
|
21
|
+
2. **Negative / before-state framing** - describing removed code, or leading with "not X" when X was never the point. Keep "not X, it's Y" only for a genuine edge case, a non-obvious gotcha, or bug-fix rationale worth flagging; otherwise state the current behavior directly.
|
|
22
|
+
3. **AI buzzwords** in place of plain software engineering vocabulary. Common offenders and their replacements:
|
|
23
|
+
- `gate` / `gates` / `gating` → "check", "decides whether to show...", "authentication check", "feature check"
|
|
24
|
+
- `guard` → "check" / "prevent"
|
|
25
|
+
- `backstop` → "handles the case where..." / "protects us either way"
|
|
26
|
+
- `load-bearing` → "useful"
|
|
27
|
+
- `predicate` → "helper"
|
|
28
|
+
- `presentational` (component) → "this component only displays the result"
|
|
29
|
+
- "flattens to a bare new Error" → "turns into a plain new Error"
|
|
30
|
+
- `harness` → name the concrete thing: "test setup", "test wrapper", "fixture", "mock server", or "runner"
|
|
31
|
+
- `realm` → "environment", "runtime", or "context" (unless it's the precise JS-realm technical term)
|
|
32
|
+
- `landed` → "implemented"
|
|
33
|
+
- `mint` → "created"
|
|
34
|
+
- `leverage` → "use"
|
|
35
|
+
- `utilize` → "use"
|
|
36
|
+
- `surface` → "show" / "return" / "report"
|
|
37
|
+
- `plumb` / `wire` → "pass" / "connect" / "call"
|
|
38
|
+
- `broker` → "handle" / "route" / name the service/helper (keep broker if that's the concept name in some architecture, library, service, etc.)
|
|
39
|
+
- `canonical` → "shared" / "standard"
|
|
40
|
+
- `churn` → "unnecessary changes"
|
|
41
|
+
- `invariant` → "rule" / "condition" / "constraint" / or something like "state that must stay true"
|
|
42
|
+
|
|
43
|
+
Do not apply this as a blind blacklist. Keep the original word when it is the precise domain term, especially for security, type-system, React, database, FHIR, or JavaScript runtime concepts. For example, `opaque` is a precise security/API term for an id, token, or string whose structure and meaning a caller must not depend on ("recipient is an opaque viewer-supplied string; never log it"); it is not on the replacement list above, so leave it — "generic" would drop that contract.
|
|
44
|
+
4. **Undocumented return shapes.** A function returning an object or union should say what each field/variant means, not just that it "returns a result."
|
|
45
|
+
5. **Any other AI-slop or AI-lingo words, framing, structure.**
|
|
46
|
+
|
|
47
|
+
## What to leave alone
|
|
48
|
+
|
|
49
|
+
- Function, type, and API names - this is a comment-only pass.
|
|
50
|
+
- Log strings and error messages shown to users.
|
|
51
|
+
- Generated files (e.g. `*.gen.ts`, Prisma client output).
|
|
52
|
+
- Genuine security markers or contracts written as caps-negatives (`NEVER log PHI`, `Does NOT upsert`) - these are useful warnings, not slop.
|
|
53
|
+
- User-facing UI copy (JSX text nodes rendered to the browser) - that's product copy, not a code comment.
|
|
54
|
+
- Already-clear one-liners that don't exhibit any of the problems above. Don't rewrite for the sake of rewriting.
|
|
55
|
+
|
|
56
|
+
## Process
|
|
57
|
+
|
|
58
|
+
1. Build the file list per Scope above.
|
|
59
|
+
2. For each file, read every comment/doc block (`//`, `/* */`, `/** */`, `#`, `%`, depending on the language) and check it against "What counts as slop." Skip anything covered by "What to leave alone."
|
|
60
|
+
3. Rewrite in place with `Edit`. Prefer one idea per sentence. When a function's return type is a union or object with meaningful fields, spell out what each branch/field means using the comment body or JSDoc (or similar) if it's a project pattern.
|
|
61
|
+
4. After editing, confirm the diff is comment-only: `git diff -- <files>` should show no code-line changes, only comment text.
|
|
62
|
+
5. Run the project's lint/typecheck on touched files (e.g. `pnpm biome check <files>`, `pnpm --filter <pkg> typecheck`) to confirm nothing broke.
|
|
63
|
+
6. Summarize what changed in 1-2 sentences - don't restate every line edited.
|
|
64
|
+
|
|
65
|
+
## Example
|
|
66
|
+
|
|
67
|
+
Bad (dense, negative framing, buzzwords):
|
|
68
|
+
```
|
|
69
|
+
// Server-to-server auth gate. Fail closed if the token is unconfigured.
|
|
70
|
+
try {
|
|
71
|
+
if (!verifyServiceAuth(request)) { ... }
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Good (Simple English, positive framing, no AI-lingo structure):
|
|
76
|
+
```
|
|
77
|
+
// Server-to-server authentication check. Deny the request if the token is unconfigured.
|
|
78
|
+
try {
|
|
79
|
+
if (!verifyServiceAuth(request)) { ... }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Bad:
|
|
84
|
+
```
|
|
85
|
+
// NOTE: this forward-block is a UI courtesy only, not a per-step gate.
|
|
86
|
+
// `currentStepIndex` derives from the current URL path — not from saved
|
|
87
|
+
// progress — so navigating to a later step's URL directly bypasses it
|
|
88
|
+
// entirely. The template deliberately ships no per-step route guards
|
|
89
|
+
// (forks may want different ordering/skipping rules); the integrity
|
|
90
|
+
// backstop is the server-side readiness check at intake completion,
|
|
91
|
+
// which refuses to finalize while any step is missing.
|
|
92
|
+
const canNavigateToStep = useCallback(...)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Good:
|
|
96
|
+
```
|
|
97
|
+
// This limits forward navigation in the UI only. `currentStepIndex` comes from
|
|
98
|
+
// the current URL path, not from saved progress, so visiting a later step's URL
|
|
99
|
+
// directly still works. The template intentionally ships no per-step route
|
|
100
|
+
// checks, since forks may want different ordering or skipping rules. The real
|
|
101
|
+
// safeguard is the server-side check at intake completion, which refuses to
|
|
102
|
+
// finalize while any step is missing.
|
|
103
|
+
const canNavigateToStep = useCallback(...)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Bad:
|
|
107
|
+
```
|
|
108
|
+
// Throttle first — cheapest gate. Bounds Argon2 CPU amplification and
|
|
109
|
+
// request flooding on this public endpoint (best-effort, per-instance).
|
|
110
|
+
const rate = await checkRateLimit(getClientKey(request))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Good:
|
|
114
|
+
```
|
|
115
|
+
// Check the rate limit first, since it's the cheapest check to run. Bounds
|
|
116
|
+
// Argon2 CPU amplification and request flooding on this public endpoint.
|
|
117
|
+
const rate = await checkRateLimit(getClientKey(request))
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Bad:
|
|
121
|
+
```
|
|
122
|
+
/**
|
|
123
|
+
* Whether the patient may finalize their intake: every required step must be
|
|
124
|
+
* validly filled and one of the payment steps must have a saved response.
|
|
125
|
+
* Server-authoritative — `completeIntake` enforces the same rule, so a client
|
|
126
|
+
* skipping this check cannot complete a partial intake.
|
|
127
|
+
*
|
|
128
|
+
* Required steps are validated (not presence-checked) so a Save-for-later draft
|
|
129
|
+
* counts as still-missing: the patient is sent back to the earliest unfinished
|
|
130
|
+
* step (e.g. Demographics) rather than skipped past it to a later one.
|
|
131
|
+
*/
|
|
132
|
+
export async function getIntakeReadiness(headers: Headers): Promise<IntakeReadiness> { ... }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Good:
|
|
136
|
+
```
|
|
137
|
+
/**
|
|
138
|
+
* Returns whether the patient can finalize their intake: every required step
|
|
139
|
+
* must pass its schema, and one of the payment steps must have a saved
|
|
140
|
+
* response. `completeIntake` enforces the same rule on the server, so a
|
|
141
|
+
* client cannot complete a partial intake by skipping this check. A
|
|
142
|
+
* Save-for-later draft counts as missing, which sends the patient back to
|
|
143
|
+
* the earliest unfinished step (e.g. Demographics) instead of skipping past
|
|
144
|
+
* it. In the returned object, `ready` tells whether the intake can be
|
|
145
|
+
* completed, and `missingSteps` lists the keys of the steps still missing.
|
|
146
|
+
*/
|
|
147
|
+
export async function getIntakeReadiness(headers: Headers): Promise<IntakeReadiness> { ... }
|
|
148
|
+
```
|
|
@@ -203,7 +203,7 @@ If the **Plan inspection** step inferred the plan doesn't touch any of these: sy
|
|
|
203
203
|
|
|
204
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
205
|
|
|
206
|
-
The deterministic
|
|
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
207
|
|
|
208
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
209
|
|
|
@@ -236,6 +236,36 @@ Escape hatch: `VINTA_SANDBOX=off` makes the wrapper a transparent pass-through (
|
|
|
236
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
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
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
|
+
|
|
239
269
|
## Step 6 — Write the summary file
|
|
240
270
|
|
|
241
271
|
`.vinta-ai-workflows/worktrees/<name>.yaml` (committed to `.gitignore` via the existing `.vinta-ai-workflows/` umbrella). Schema:
|
|
@@ -279,9 +309,13 @@ state:
|
|
|
279
309
|
cron_disabled: <bool>
|
|
280
310
|
sandbox:
|
|
281
311
|
tier: enforced | none # from the Filesystem sandbox step's capability probe
|
|
282
|
-
launcher: sandbox-exec | bwrap |
|
|
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)
|
|
283
317
|
deny: [<main-checkout-root>] # subtree(s) made read-only inside the cage
|
|
284
|
-
allow: [<worktree-path>, <.vinta-ai-workflows-dir>]
|
|
318
|
+
allow: [<worktree-path>, <main-checkout-root>/.git, <.vinta-ai-workflows-dir>] # writable exceptions
|
|
285
319
|
notes: |
|
|
286
320
|
<freeform — anything the user / agent should know>
|
|
287
321
|
```
|
|
@@ -349,7 +383,8 @@ Every step gated on user confirmation when the worktree has un-pushed branches.
|
|
|
349
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.
|
|
350
384
|
- **Worktree root governed by runtime conventions.** claude-code uses `.claude/worktrees/`; other harnesses use sibling dirs. Don't fight the harness — match it.
|
|
351
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.
|
|
352
|
-
- **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
|
|
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`.
|
|
353
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.
|
|
354
389
|
- **Worktree base = `origin/<default-branch>` by default.** `HEAD` only when the user explicitly confirms; record the choice in the summary.
|
|
355
390
|
- **Refuse to provision a second worktree for the same branch.** Git enforces this — don't try to work around it.
|
|
@@ -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."
|
|
@@ -75,7 +75,9 @@ fi
|
|
|
75
75
|
|
|
76
76
|
# ---- macOS: sandbox-exec -----------------------------------------------------
|
|
77
77
|
if command -v sandbox-exec >/dev/null 2>&1; then
|
|
78
|
-
|
|
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"
|
|
79
81
|
trap 'rm -f "$profile"' EXIT
|
|
80
82
|
{
|
|
81
83
|
echo '(version 1)'
|