Skip to content

provision_worktree: the hook-registration loop writes through a symlinked config path #421

Description

@pbean

Found while closing out PR #406. That PR made this fault reported (#415's worktree_seed_undelivered gate, extended by baa2bac's config_paths arm); it did not make it refused. Filing the refusal half separately rather than leaving it in a phase log.

What

Every write in provision_worktree is guarded — the skills merge asks _occupied and a containment test, both explicit seed loops split raw from dst and refuse to write through a link — except the per-CLI hook-registration loop (install.py:2128-2147):

for profile in profiles:
    if profile.hookless:
        continue
    config_path = worktree / profile.hooks.config_path
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config: dict = {}
    if config_path.is_file():
        ...
    config, changed = merge_hooks(config, registrations, profile.hooks.dialect)
    if changed:
        config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")

No containment guard, no _occupied, no is_symlink. If the checkout carries .claude/settings.json (or .gemini/settings.json, .github/copilot/settings.json, .agents/hooks.json) as a symlink pointing outside the worktree — a very ordinary dotfiles arrangement — then:

  • config_path.is_file() follows the link and reads the outside file, so the merge starts from a config the worktree does not own; and
  • write_text follows it too, so the hook registrations are written into the user's real dotfile, outside the worktree, rather than into the isolated checkout.

A dangling link is the other half: is_file() is False, and the write then lands at the link's target.

Why it matters

The registrations baked in are worktree-specific — they name an absolute relay path (repo_root / HOOK_SCRIPT_REL) for this run. Writing them through a link mutates shared user config with per-run content, and the worktree that was supposed to receive them does not have them, so the Stop hook never fires for that unit.

worktree_seed_undelivered's config_paths arm now reports the three shapes (source escapes the repo, destination is a symlink, destination escapes the worktree) through worktree-seed-dropped — so the operator finds out. Nothing stops the write.

Shape of a fix

Give this loop the same treatment the seed loops got in baa2bac: split raw = worktree / profile.hooks.config_path from dst = raw.resolve(), and refuse when dst != raw (or when dst escapes the worktree). The decision worth making deliberately is whether a refusal here should be silent-and-reported, like a dropped seed, or should escalate — a worktree with no Stop hook does not fail loudly, it just never reports completion, which is the failure mode the completion-signal invariant exists to prevent.

Note _worktree_local_exclude also writes without these guards (install.py:1314/:1321); that one targets the git common dir by design and is deliberately out of scope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions