Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b250749
test: add real-world git command corpus for hook gate parser
ashmitb95 Jul 4, 2026
3f9026a
feat(hooks): top-level shell command splitter for the bash gate
ashmitb95 Jul 4, 2026
dd5c51a
feat(hooks): per-segment effective-dir resolution (cd chains, git -C)
ashmitb95 Jul 4, 2026
840dfd5
feat(hooks): mutation classification + 680-shape corpus regression
ashmitb95 Jul 4, 2026
69d763a
feat(hooks): path gate — git mutations must run inside a workspace repo
ashmitb95 Jul 4, 2026
8edd7ec
feat(hooks): branch-drift gate for commit/push in trunk and slots
ashmitb95 Jul 4, 2026
d63ee34
feat(hooks): push refspec gate — catch cross-repo branch-name pushes
ashmitb95 Jul 4, 2026
a87d96c
feat(hooks): run_gate payload wrapper — fast path + fail-open + CANOP…
ashmitb95 Jul 4, 2026
ea4ee9a
fix(hooks): review round 1 — push-redirect false block, -c/-C and cd-…
ashmitb95 Jul 4, 2026
6b6f99b
feat(hooks): SessionStart context brief
ashmitb95 Jul 4, 2026
bcdb706
fix(hooks): heredoc-aware newline splitting; stash flag forms are mut…
ashmitb95 Jul 4, 2026
6d91563
feat(hooks): lightweight canopy-hook-gate/-context entry points
ashmitb95 Jul 4, 2026
3297007
fix(hooks): heredoc edge cases — backslash delimiters, exact terminat…
ashmitb95 Jul 4, 2026
886bfa0
feat(setup-agent): --hooks installs the enforcement hooks (project-sc…
ashmitb95 Jul 4, 2026
c9b8fc5
docs: enforcement hooks + 3.2.0 changelog
ashmitb95 Jul 4, 2026
695d391
fix(hooks): review round 2 — installer shape-safety + atomic write, -…
ashmitb95 Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ Tracks the Python side (CLI + MCP server). The VSCode extension has its own [vsc

Versions follow semver. Pre-1.0 — minor bumps may add features or break behavior; the README is the source-of-truth contract.

## 3.2.0 — 2026-07-04 (Enforcement hooks — 4.0 phase 2)

### Added
- Claude Code enforcement hooks, installed by `canopy setup-agent --hooks`
(project-scoped `.claude/settings.json`):
- **PreToolUse git gate** (`canopy-hook-gate`): blocks git mutations whose
effective directory (after resolving `cd` chains, `git -C`, and heredoc
bodies) is outside a workspace repo (`outside_repo`), commits/pushes on
a branch belonging to a different registered feature
(`trunk_branch_drift` / `slot_branch_drift`), and pushes of branch names
that only exist in a different repo (`push_unknown_branch`). Fail-open
by design; `CANOPY_HOOKS_DISABLED=1` disables.
- **SessionStart brief** (`canopy-hook-context`): injects a compact
repo → branch → canonical-feature map at session start.
- `tests/fixtures/hook_gate_corpus.jsonl`: 680 real-world git command shapes
(mined from 35 days of transcripts) as a parser regression corpus.

## 3.1.2 — 2026-07-04

Slot-model consistency fixes from canopy-test dogfooding.
Expand Down
35 changes: 35 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,41 @@ Install-staleness (canopy's installation around the workspace):
| `skill_stale` | warn | installed skill drifted from bundled source | `install_skill(reinstall=True)` |
| `vsix_duplicates` | info | multiple `singularityinc.canopy-*` extension dirs | requires `--clean-vsix` |

## Hooks (enforcement)

Claude Code hooks that stop the agent from mutating git state in the wrong place. Separate from the drift-tracking `canopy hooks install|uninstall|status` (post-checkout, feeds `heads.json`) described in "Setup" above.

| Command | What it does |
|---|---|
| `canopy setup-agent --hooks` | Installs (or refreshes) the enforcement hooks into `<workspace>/.claude/settings.json`: a `PreToolUse` entry (matcher `Bash`) running `canopy-hook-gate`, and a `SessionStart` entry running `canopy-hook-context`. **Project-scoped, not user-scoped** — the workspace root is normally not itself a git repo (it's a container of repos), so nothing lands in `~/.claude/settings.json` or in any employer repo's tree. Merges into existing `settings.json`: other keys (`permissions`, foreign hooks) are preserved untouched; re-running is a no-op (`action: "unchanged"`) once both entries are present. If `settings.json` exists but isn't valid JSON, install is skipped with a `reason` rather than clobbering it. Combine with the other `setup-agent` flags (`--skill-only`, `--mcp-only`, `--reinstall`, `--check`) as usual. |

`canopy-hook-gate` and `canopy-hook-context` are internal console scripts (registered in `pyproject.toml`, not meant to be run by hand) that Claude Code invokes per the `settings.json` entries above:

- **`canopy-hook-gate`** (PreToolUse, matcher `Bash`) — reads the tool-call payload as JSON on stdin (`{tool_name, tool_input: {command}, cwd, ...}`). For non-`Bash` calls or commands with no `git` token, exits 0 immediately without touching disk. Otherwise it resolves the workspace from `cwd` (walking up for `canopy.toml`), splits the command on top-level shell operators, tracks the effective directory through `cd` chains and `git -C`, and judges only the mutating git subcommands (`commit`, `push`, `merge`, `rebase`, `reset`, `cherry-pick`, `add`, `rm`, `mv`, `am`, `revert`, mutating `stash` verbs). **Exit 0** = allow (nothing printed). **Exit 2** = block, with a one-line reason on stderr that Claude Code feeds back to the model.
- **`canopy-hook-context`** (SessionStart) — reads the same payload shape, resolves the workspace from `cwd`, and prints a compact brief to stdout (which becomes session context): workspace name, canonical feature, each repo's branch + dirty count, each warm slot's occupant, and a one-line reminder to `canopy switch` before working if the ticket doesn't match. Always exits 0; on any error it prints nothing.

Deny codes (all four block with an explanatory message that also names the fix):

| Code | Meaning | Fix the message names |
|---|---|---|
| `outside_repo` | The mutation's effective directory (after resolving `cd`/`git -C`) isn't inside any workspace repo or slot worktree. | `cd <repo> && git ...`, or use `canopy run`. |
| `trunk_branch_drift` | On **commit/push only**: a canonical-slot repo is on a branch owned by a different registered feature than the current canonical one. (Other mutations like `git add` on a drifted branch are allowed.) | `canopy switch <feature>` (either the branch's owner, or back to canonical). |
| `slot_branch_drift` | On **commit/push only**: a warm-slot repo is on a branch that doesn't match the slot's recorded occupant feature. | `git checkout <expected-branch>` in that worktree, or `canopy doctor`. |
| `push_unknown_branch` | `git push`'s source refspec names a branch that doesn't exist in the effective repo (but does exist in a different one). | Check the branch for *this* repo with `git branch --list` or `canopy context`; likely the wrong repo. |

**Fail-open contract.** The gate only blocks when it's sure the mutation targets the wrong place. It allows (exit 0) on: unparseable shell segments (`shlex` failure), unresolvable `cd` targets (`$VAR`, `~`, backticks, `cd -`), a `cwd` with no `canopy.toml` anywhere above it, non-`Bash` tool calls, commands with no `git` token, and any internal exception — `run_gate` never raises. `checkout`/`switch` are deliberately never gated: they're the recovery action for a drifted branch, and blocking them would trap the agent.

**Escape hatch:** set `CANOPY_HOOKS_DISABLED=1` in the environment to make the gate a no-op (checked first, before any parsing).

**Known bypasses** (deliberate fail-open — not bugs, documented so nobody relies on the gate as a security boundary):
- Env-prefix invocations: `GIT_TRACE=1 git push`, `env git push` — the leading token isn't `git`, so the segment isn't recognized as a git mutation.
- Non-literal git: `/usr/bin/git ...`, `command git ...`, `sh -c "git push"`, `xargs git push` — same reason, no literal `git` argv[0].
- Subshells, loops, brace groups: `(cd x && git push)`, `for d in a b; do (cd "$d" && git push); done` — the gate's segment splitter is top-level-operator-aware but doesn't recurse into subshell/loop bodies.
- Unresolvable directories: `cd $DIR`, `cd ~/x`, `git -C "$dir"`, or any `--git-dir`/`--work-tree` override — these poison `dir_known` for the segment (or everything after, for an unresolvable `cd`), which fails open rather than guessing.
- Shlex-unparseable segments: unbalanced quotes cause that segment to be skipped entirely.
- Backslash-escape edge cases in the quote-tracking scanner (best-effort, not a full shell parser).
- Sessions whose `cwd` is outside the workspace entirely — no `canopy.toml` is found walking up, so the gate can't resolve repos/slots and allows everything.

## Debug

| Command | What it does |
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ dev = [
[project.scripts]
canopy = "canopy.cli.main:main"
canopy-mcp = "canopy.mcp.server:main"
canopy-hook-gate = "canopy.hooks_entry:gate_main"
canopy-hook-context = "canopy.hooks_entry:context_main"

[tool.hatch.version]
path = "src/canopy/__init__.py"
Expand Down
2 changes: 1 addition & 1 deletion src/canopy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Canopy — workspace-first development orchestrator."""
__version__ = "3.1.2"
__version__ = "3.2.0"
45 changes: 45 additions & 0 deletions src/canopy/actions/hook_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""SessionStart brief — one compact block injected into a new session.

Evidence: 111 midway branch switches in 35 days, 87 after 10+ edits. The
mismatch must be visible BEFORE the agent reads a single file. Keep this
under ~10 lines: it lands in every session's context budget.
"""
from __future__ import annotations

import re

from ..workspace.workspace import Workspace

_SLOT_NUM = re.compile(r"worktree-(\d+)$")


def _slot_sort_key(sid: str) -> tuple[int, int, str]:
"""Sort worktree-N slots numerically; other ids fall back to name order."""
m = _SLOT_NUM.match(sid)
return (0, int(m.group(1)), "") if m else (1, 0, sid)


def context_brief(workspace: Workspace) -> str:
from . import slots as slots_mod

state = slots_mod.read_state(workspace)
canonical = state.canonical.feature if state and state.canonical else None
lines = [
f"canopy: workspace '{workspace.config.name}' — "
f"canonical feature: {canonical or '(none)'}",
]
for rs in sorted(workspace.repos, key=lambda r: r.config.name):
name = rs.config.name
if not rs.abs_path.exists():
lines.append(f" {name} → (missing on disk)")
continue
dirty = f"{rs.dirty_count} dirty" if rs.is_dirty else "clean"
lines.append(f" {name} → {rs.current_branch} ({dirty})")
if state and state.slots:
for sid in sorted(state.slots, key=_slot_sort_key):
lines.append(f" slot {sid} → {state.slots[sid].feature}")
lines.append(
" Before any work: confirm the branch above matches this chat's "
"ticket. If not, run `canopy switch <feature>` FIRST."
)
return "\n".join(lines)
Loading
Loading