Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ After pushing, poll PR checks and review comments in a single loop for up to 10
## Key Design Decisions

- **Three-layer sandbox**: Session resolution -> context-aware path checking -> git safety regex
- **H-4 purity**: PreToolUse hook (`permissions`) NEVER writes files. Uses `resolve_readonly()`
- **H-4 purity**: PreToolUse hook (`permissions`) NEVER writes files. Uses `resolve_readonly()`.
- **Lazy worktrees**: `WORKTREE_MISSING:<repo>` denials trigger `ensure-worktree` on-demand
- **Config persistence**: `.agents/`, `.claude/` redirect to main checkout when gitignored; if tracked by git (dir exists in worktree), allowed in-place
- **Committed repo files**: `CLAUDE.md`, `AGENTS.md` are version-controlled — allowed in worktrees
Expand Down Expand Up @@ -177,7 +177,7 @@ All shell scripts follow the [Google Shell Style Guide](https://google.github.io

## Testing

219 tests (166 unit + 5 doc + 13 integration + 10 proptest + 25 memory) plus 4 fuzz targets.
246 tests (188 hooks unit + 5 claude_md + 17 integration + 10 proptest + 22 memory unit + 4 memory integration) plus 4 fuzz targets.
Run with `make test` or `cargo test`.

Test patterns:
Expand Down Expand Up @@ -230,5 +230,5 @@ Every workflow change must pass `actionlint` + `zizmor --pedantic` in CI.

## Dependencies

5 crates: `serde`, `serde_json`, `regex`, `flate2`, `libc`. No async runtime,
no network deps, no proc macros.
5 crates: `serde`, `serde_json`, `regex`, `flate2`, `libc`. Memory crate adds
`rusqlite`. No async runtime, no network deps, no proc macros.
69 changes: 51 additions & 18 deletions hooks/src/gitcheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,74 +72,106 @@ static RE_GIT_C_PATH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\bgit\s+-C\s+("[^"]+"|'[^']+'|(\S+))"#).unwrap());

/// Run all 8 git safety checks against a Bash command.
///
/// Denial messages use the WHAT/FIX/REF remediation format so the agent
/// can self-repair without human intervention.
pub fn check_git_safety(cmd: &str) -> GitResult {
// FR-GS-1: Force push without --force-with-lease
if RE_GIT_PUSH.is_match(cmd)
&& RE_FORCE_FLAG.is_match(cmd)
&& !RE_FORCE_WITH_LEASE.is_match(cmd)
{
return GitResult::Block(
"BLOCKED: Force push without --force-with-lease. Use: git push --force-with-lease origin <branch>".into(),
"WHAT: Force push without --force-with-lease. \
FIX: Use `git push --force-with-lease origin <branch>` instead. \
REF: CLAUDE.md#supply-chain-policy"
.into(),
);
}

// FR-GS-2: Push to main/master
if RE_PUSH_TO_MAIN.is_match(cmd) {
return GitResult::Block(
"BLOCKED: Direct push to main/master. Create a feature branch and open a PR instead."
"WHAT: Direct push to main/master. \
FIX: Create a feature branch and open a PR instead. \
REF: CLAUDE.md#commit-convention"
.into(),
);
}

// FR-GS-3: Refspec push to main/master
if RE_REFSPEC_MAIN.is_match(cmd) {
return GitResult::Block(
"BLOCKED: Push to main/master via refspec. Create a feature branch and open a PR instead."
"WHAT: Push to main/master via refspec. \
FIX: Create a feature branch and open a PR instead. \
REF: CLAUDE.md#commit-convention"
.into(),
);
}

// FR-GS-4: Delete main/master
if RE_DELETE_MAIN.is_match(cmd) {
return GitResult::Block("BLOCKED: Deleting main/master branch is not allowed.".into());
return GitResult::Block(
"WHAT: Deleting main/master branch is not allowed. \
FIX: Do not delete protected branches. \
REF: CLAUDE.md#supply-chain-policy"
.into(),
);
}
if RE_DELETE_REFSPEC.is_match(cmd) {
return GitResult::Block(
"BLOCKED: Deleting main/master branch via empty refspec is not allowed.".into(),
"WHAT: Deleting main/master branch via empty refspec is not allowed. \
FIX: Do not delete protected branches. \
REF: CLAUDE.md#supply-chain-policy"
.into(),
);
}

// FR-GS-5: --no-verify
if RE_NO_VERIFY.is_match(cmd) {
return GitResult::Block(
"BLOCKED: git push --no-verify bypasses pre-push hooks. Fix the hook failures instead."
"WHAT: git push --no-verify bypasses pre-push hooks. \
FIX: Fix the hook failures instead of skipping them. \
REF: CLAUDE.md#lint-suppression-policy"
.into(),
);
}

// FR-GS-6: --follow-tags
if RE_FOLLOW_TAGS.is_match(cmd) {
return GitResult::Block(
"BLOCKED: git push --follow-tags pushes ALL matching local tags. Push tags explicitly: git push origin <tag>".into(),
"WHAT: git push --follow-tags pushes ALL matching local tags. \
FIX: Push tags explicitly: `git push origin <tag>`. \
REF: CLAUDE.md#releases"
.into(),
);
}

// FR-GS-7: Delete semver tags (local and remote)
if RE_DELETE_SEMVER_TAG.is_match(cmd) {
return GitResult::Block(
"BLOCKED: Deleting semantic version tags is not allowed. Release a new patch version instead.".into(),
"WHAT: Deleting semantic version tags is not allowed. \
FIX: Release a new patch version instead. \
REF: CLAUDE.md#releases"
.into(),
);
}
if RE_DELETE_REMOTE_TAG.is_match(cmd) {
return GitResult::Block(
"BLOCKED: Deleting remote semantic version tags is not allowed. Release a new patch version instead.".into(),
"WHAT: Deleting remote semantic version tags is not allowed. \
FIX: Release a new patch version instead. \
REF: CLAUDE.md#releases"
.into(),
);
}

// FR-GS-8: Hard reset to origin/main|master
if RE_HARD_RESET.is_match(cmd) {
return GitResult::Block(
"BLOCKED: git reset --hard origin/main|master discards all local work. Use: git stash or git reset --soft".into(),
"WHAT: git reset --hard origin/main discards all local work. \
FIX: Use `git stash` or `git reset --soft` instead. \
REF: CLAUDE.md#key-design-decisions"
.into(),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -204,9 +236,9 @@ pub fn check_worktree_enforcement(
return Some(crate::worktree_missing_msg(&repo));
}
return Some(format!(
"BLOCKED: Git op on main checkout ({repo}). \
Use worktree: {ws_str}/{repo}/.worktrees/{short_id}. \
Tip: run git -C <wt-path> fetch origin before creating new branches"
"WHAT: Git operation targets main checkout ({repo}), not the session worktree. \
FIX: Use `git -C {ws_str}/{repo}/.worktrees/{short_id}/` instead. \
REF: docs/architecture.md#key-invariants"
));
}
}
Expand All @@ -228,9 +260,9 @@ pub fn check_worktree_enforcement(
return Some(crate::worktree_missing_msg(&repo));
}
return Some(format!(
"BLOCKED: Git op on main checkout ({repo}). \
Use worktree: {ws_str}/{repo}/.worktrees/{short_id}. \
Tip: run git -C <wt-path> fetch origin before creating new branches"
"WHAT: Git operation targets main checkout ({repo}), not the session worktree. \
FIX: Use `git -C {ws_str}/{repo}/.worktrees/{short_id}/` instead. \
REF: docs/architecture.md#key-invariants"
));
}
}
Expand All @@ -241,8 +273,9 @@ pub fn check_worktree_enforcement(
if !RE_GIT_C.is_match(cmd) && !RE_CD_PATH.is_match(cmd) && RE_GIT_CHECKOUT_SWITCH.is_match(cmd)
{
return Some(format!(
"BLOCKED: Bare git checkout/switch — worktrees are active. Use: git -C <repo>/.worktrees/{}/ checkout ...",
short_id
"WHAT: Bare git checkout/switch — worktrees are active. \
FIX: Use `git -C <repo>/.worktrees/{short_id}/ checkout ...` instead. \
REF: docs/architecture.md#key-invariants"
));
}

Expand Down
8 changes: 6 additions & 2 deletions hooks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ pub mod session;
pub mod worktree;

/// Format a WORKTREE_MISSING denial message for lazy worktree creation.
///
/// Uses the WHAT/FIX/REF remediation format so the agent can self-repair.
pub fn worktree_missing_msg(repo: &str) -> String {
let bin = config::bin_dir().join("ensure-worktree");
format!(
"WORKTREE_MISSING:{repo} \
— Run: {} {repo}",
"WORKTREE_MISSING:{repo} — \
WHAT: No worktree exists for repo '{repo}' in this session. \
FIX: Run `{} {repo}` to create one, then retry the write. \
REF: docs/architecture.md#key-invariants",
bin.display()
)
}
Expand Down
12 changes: 9 additions & 3 deletions hooks/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,12 @@ pub fn check_path_with_context(
return PathDecision::Deny(crate::worktree_missing_msg(&repo));
}
let rel = extract_rel_path(&resolved, &repo, ws_str);
let wt_path =
format!("{}/{}/.worktrees/{}/{}", ws_str, repo, sess.short_id, rel);
return PathDecision::Deny(format!(
"REDIRECT: Use worktree path instead: {}/{}/.worktrees/{}/{}",
ws_str, repo, sess.short_id, rel
"WHAT: Write targets main checkout, not the session worktree. \
FIX: Use the worktree path instead: {wt_path}. \
REF: docs/architecture.md#key-invariants"
));
}
} else {
Expand All @@ -190,7 +193,10 @@ pub fn check_path_with_context(
return PathDecision::Deny(crate::worktree_missing_msg(&repo));
}
return PathDecision::Deny(
"BLOCKED: No worktree for this session. All repo writes are blocked to prevent editing the main checkout.".into(),
"WHAT: No worktree exists and repo name could not be extracted. \
FIX: Ensure the write path is inside a known repo directory. \
REF: docs/architecture.md#key-invariants"
.into(),
);
}

Expand Down
Loading