Skip to content
Merged
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
6 changes: 3 additions & 3 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ git-courer uses four hook events. Each event has a matcher (which agent actions

| Event | Matcher | Command | Fires When | Purpose |
|-------|---------|---------|------------|---------|
| `PreToolUse` | `Bash` (Claude Code, Codex) / `run_command` (Antigravity) | `git-courer hook-check` | The agent is about to run a shell command | Classify the command; if it's a git command, emit `additionalContext` suggesting the git-courer MCP tool. **Never denies** — only suggests. |
| `PreToolUse` | `Bash` (Claude Code, Codex) / `run_command` (Antigravity) | `git-courer hook-check` | The agent is about to run a shell command | Classify the command; if it's a covered git subcommand, emit a deny decision pointing the agent at the git-courer MCP tool. **Denies** covered git subcommands with a message pointing to the equivalent git-courer MCP tool. |
| `SessionStart` | `startup\|resume` | `git-courer session-start-hook` | A new agent session starts (or resumes) | Inject the Golden Rules into the agent's context so the workflow is known from the first turn. |
| `SubagentStart` | `general-purpose\|Explore\|Plan` | `git-courer subagent-start-hook` | A subagent is spawned | Inject the Golden Rules into the subagent's context, so subagents also follow `session start` → `status` → `diff`/`review` → `pr-review`. |
| `PreInvocation` | *(empty matcher)* | `git-courer pre-invocation-hook` | Before every model call (Antigravity only) | Inject the Golden Rules before each model invocation. Compensates for clients that have no `SessionStart`/`SubagentStart` events. |
Expand All @@ -32,7 +32,7 @@ Every hook subcommand emits a JSON object on stdout shaped for the Codex hook co

- `hookEventName` echoes the event (`PreToolUse`, `SessionStart`, `SubagentStart`, `PreInvocation`).
- `additionalContext` is the Golden Rules markdown for the three context-injection hooks. For `PreToolUse` it is a short suggestion like `Use git-courer/<tool> instead of bash <command>` when the command is a git command; for non-git commands, `hook-check` exits cleanly with **no output** (no decision is emitted, no command is denied).
- `permissionDecision` / `permissionDecisionReason` are **never set** by git-courer — it never denies a command. The agent (and the user, via the client's own permission prompt) always retains the final say.
- `permissionDecision` / `permissionDecisionReason` are set to `'deny'` for covered git subcommands.

---

Expand Down Expand Up @@ -72,7 +72,7 @@ Clients store hooks differently and support different event subsets. git-courer
### OpenCode — no hooks (policy + prompt block instead)

- **No hooks**: OpenCode does not support lifecycle hooks. git-courer instead injects:
- `permission.bash["git *"] = "ask"` into `opencode.json` — OpenCode asks before raw git commands.
- `permission.bash["git {sub}"] = "deny"` (granular, one entry per covered subcommand) plus `"git *": "ask"` as a fallback into `opencode.json` — OpenCode denies the 23 covered git subcommands and asks before any other raw git command.
- The `AGENTS.md` path into the `instructions` array — so OpenCode reads the Golden Rules from the prompt block it already loads.
- See [mcp-clients.md § OpenCode Policy Injection](./mcp-clients.md#5-opencode-policy-injection).

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ The MCP tool form returns the same diagnostic envelope as structured JSON (engra
git-courer hook-check "<shell command>"
```

This is the command the `PreToolUse` hooks invoke. It classifies a shell command via the gitcmd classifier and emits the result as JSON on stdout. For git commands it includes `additionalContext` suggesting the git-courer MCP tool instead of bash — it **never denies** a command, it only nudges.
This is the command the `PreToolUse` hooks invoke. It classifies a shell command via the gitcmd classifier and emits the result as JSON on stdout. For the 23 git subcommands covered by git-courer MCP tools it emits `permissionDecision: "deny"` with a message pointing to the equivalent tool. Unknown git subcommands also get deny as a safe default. Non-git commands and uncovered git subcommands exit cleanly with no output.

When invoked with no args and stdin is a pipe (Codex hook mode), it reads the Codex hook JSON from stdin, extracts the command, classifies it, and emits a Codex-shaped `hookSpecificOutput` with `additionalContext`. Non-git commands exit cleanly with no output.

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ If `doctor` reports `MCP configured: no` but you already ran `mcp setup`, the cl

## `hook-check` troubleshooting

`hook-check` classifies a shell command and emits JSON; it **never denies** a command — it only adds `additionalContext` suggesting the git-courer MCP tool for git commands.
`hook-check` classifies a shell command and emits JSON. For the 23 git subcommands covered by git-courer MCP tools (status, diff, commit, etc.) it emits `permissionDecision: "deny"` with a message pointing to the equivalent tool. For unknown git subcommands it also emits deny as a safe default. Non-git commands and uncovered git subcommands (init, clone, config, etc.) exit cleanly with no output.

```bash
# Direct invocation
Expand Down
113 changes: 62 additions & 51 deletions internal/classifier/gitcmd/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import "strings"
//
// Decision is one of:
// - "allow" — the command is safe to run as-is (non-git, maintenance, etc.)
// - "ask" — the command is a git mutation/read the agent should run via
// the suggested MCP tool instead.
// - "deny" — the command is a git subcommand covered by a git-courer MCP
// tool; the agent should use that tool instead of raw git.
// - "ask" — the command is an unknown git subcommand with no known MCP
// equivalent; the safe default is to ask before running.
//
// MCPTool is the git-courer MCP tool name that should be used in place of the
// raw git subcommand. It is empty when Decision is "allow".
// raw git subcommand. It is empty when Decision is "allow" or "ask".
//
// Reason is a human-readable explanation shown to the agent. For "ask"
// Reason is a human-readable explanation shown to the agent. For "deny"
// decisions it follows the format "Use git-courer/{tool} instead of bash
// git {subcommand}".
type Result struct {
Expand All @@ -30,59 +32,68 @@ type Result struct {
}

// mcpTools maps a git subcommand to the git-courer MCP tool that should be
// used instead. Maintenance and informational commands are intentionally
// absent — they are allowed directly by Classify.
// used instead. Only the 23 subcommands covered by a git-courer MCP tool are
// listed here; Classify returns "deny" for these. Maintenance and
// informational commands are intentionally absent — they are allowed directly
// by Classify.
var mcpTools = map[string]string{
"status": "status",
"diff": "diff",
"commit": "commit",
"log": "history",
"branch": "branch",
"merge": "integrate",
"rebase": "integrate",
"cherry-pick": "integrate",
"revert": "rewrite",
"reset": "rewrite",
"stash": "stash",
"push": "sync",
"pull": "sync",
"fetch": "sync",
"show": "history",
"blame": "history",
"remote": "sync",
"config": "config",
"add": "stage",
"restore": "stage",
"clean": "stage",
"rm": "stage",
"mv": "stage",
"switch": "branch",
"checkout": "branch",
"worktree": "branch",
"shortlog": "history",
"describe": "history",
"reflog": "history",
"notes": "history",
"archive": "history",
"status": "status",
"diff": "diff",
"commit": "commit",
"log": "history",
"branch": "branch",
"merge": "integrate",
"rebase": "integrate",
"cherry-pick": "integrate",
"revert": "rewrite",
"reset": "rewrite",
"stash": "stash",
"push": "sync",
"pull": "sync",
"fetch": "sync",
"blame": "history",
"add": "stage",
"restore": "stage",
"clean": "stage",
"rm": "stage",
"switch": "branch",
"checkout": "branch",
"worktree": "branch",
"reflog": "history",
}

// allowedSubcommands are git subcommands with no MCP equivalent that are safe
// to run directly (maintenance, informational, one-time setup).
// to run directly (maintenance, informational, one-time setup, and the 8
// subcommands that git-courer does not yet cover).
var allowedSubcommands = map[string]bool{
"gc": true,
"fsck": true,
"prune": true,
"repack": true,
"maintenance": true,
"help": true,
"version": true,
"init": true,
"clone": true,
"gc": true,
"fsck": true,
"init": true,
"clone": true,
"show": true,
"remote": true,
"config": true,
"mv": true,
"shortlog": true,
"describe": true,
"notes": true,
"archive": true,
"submodule": true,
"bisect": true,
"verify-commit": true,
"verify-tag": true,
"grep": true,
"cherry": true,
"ls-files": true,
"ls-tree": true,
"ls-remote": true,
"tag": true,
}

// Classify inspects a command string and returns a Result indicating whether
// the agent should run it directly ("allow") or use the corresponding
// git-courer MCP tool instead ("ask").
// the agent should run it directly ("allow"), use the corresponding
// git-courer MCP tool instead ("deny"), or prompt before running ("ask" — the
// safe default for unknown git subcommands).
//
// Classify is a pure function — it has no side effects and is deterministic
// for a given input.
Expand Down Expand Up @@ -115,7 +126,7 @@ func Classify(command string) Result {
}

if tool, ok := mcpTools[sub]; ok {
r.Decision = "ask"
r.Decision = "deny"
r.MCPTool = tool
r.Reason = "Use git-courer/" + tool + " instead of bash git " + sub
return r
Expand All @@ -126,4 +137,4 @@ func Classify(command string) Result {
r.MCPTool = ""
r.Reason = "Unknown git subcommand '" + sub + "' — check for a git-courer MCP tool before running raw git"
return r
}
}
45 changes: 26 additions & 19 deletions internal/classifier/gitcmd/classifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

// TestClassify_KnownGitSubcommands verifies each known git subcommand maps
// to the expected MCP tool with decision "ask" and a reason that references
// to the expected MCP tool with decision "deny" and a reason that references
// the suggested tool.
func TestClassify_KnownGitSubcommands(t *testing.T) {
t.Parallel()
Expand All @@ -32,23 +32,15 @@ func TestClassify_KnownGitSubcommands(t *testing.T) {
{"push", "git push", "sync", "git-courer/sync"},
{"pull", "git pull", "sync", "git-courer/sync"},
{"fetch", "git fetch", "sync", "git-courer/sync"},
{"show", "git show", "history", "git-courer/history"},
{"blame", "git blame", "history", "git-courer/history"},
{"remote", "git remote", "sync", "git-courer/sync"},
{"config", "git config", "config", "git-courer/config"},
{"add", "git add", "stage", "git-courer/stage"},
{"restore", "git restore", "stage", "git-courer/stage"},
{"clean", "git clean", "stage", "git-courer/stage"},
{"rm", "git rm", "stage", "git-courer/stage"},
{"mv", "git mv", "stage", "git-courer/stage"},
{"switch", "git switch", "branch", "git-courer/branch"},
{"checkout", "git checkout", "branch", "git-courer/branch"},
{"worktree", "git worktree", "branch", "git-courer/branch"},
{"shortlog", "git shortlog", "history", "git-courer/history"},
{"describe", "git describe", "history", "git-courer/history"},
{"reflog", "git reflog", "history", "git-courer/history"},
{"notes", "git notes", "history", "git-courer/history"},
{"archive", "git archive", "history", "git-courer/history"},
}

for _, tt := range tests {
Expand All @@ -57,8 +49,8 @@ func TestClassify_KnownGitSubcommands(t *testing.T) {
if r.Command != tt.command {
t.Errorf("Command: got %q, want %q", r.Command, tt.command)
}
if r.Decision != "ask" {
t.Errorf("Decision: got %q, want %q", r.Decision, "ask")
if r.Decision != "deny" {
t.Errorf("Decision: got %q, want %q", r.Decision, "deny")
}
if r.MCPTool != tt.wantTool {
t.Errorf("MCPTool: got %q, want %q", r.MCPTool, tt.wantTool)
Expand All @@ -81,13 +73,28 @@ func TestClassify_MaintenanceCommands(t *testing.T) {
commands := []string{
"git gc",
"git fsck",
"git prune",
"git repack",
"git maintenance",
"git help",
"git version",
"git init",
"git clone",
// The 8 subcommands git-courer does not yet cover — allowed directly.
"git show",
"git remote",
"git config",
"git mv",
"git shortlog",
"git describe",
"git notes",
"git archive",
// Additional allowed subcommands with no MCP equivalent.
"git submodule",
"git bisect",
"git verify-commit",
"git verify-tag",
"git grep",
"git cherry",
"git ls-files",
"git ls-tree",
"git ls-remote",
"git tag",
}

for _, cmd := range commands {
Expand Down Expand Up @@ -183,8 +190,8 @@ func TestClassify_GitWithSubcommandArgs(t *testing.T) {
t.Parallel()

r := Classify("git status --short --branch")
if r.Decision != "ask" {
t.Errorf("Decision: got %q, want ask", r.Decision)
if r.Decision != "deny" {
t.Errorf("Decision: got %q, want deny", r.Decision)
}
if r.MCPTool != "status" {
t.Errorf("MCPTool: got %q, want status", r.MCPTool)
Expand All @@ -200,4 +207,4 @@ func TestClassify_BareGit(t *testing.T) {
if r.Decision != "allow" {
t.Errorf("Decision: got %q, want allow (bare git prints help)", r.Decision)
}
}
}
20 changes: 11 additions & 9 deletions internal/delivery/cli/hook_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ var isStdinPipe = func() bool {
// the agent is blocked from running raw git and is pointed at the git-courer
// MCP tool. Non-git commands exit cleanly with no output.
type HookCheckCommand struct {
Stdin io.Reader // for testing; nil = os.Stdin
Stdout io.Writer // for testing; nil = os.Stdout
Stdin io.Reader // for testing; nil = os.Stdin
Stdout io.Writer // for testing; nil = os.Stdout
}

// agentType identifies the calling agent from its stdin JSON shape.
Expand All @@ -64,7 +64,7 @@ type hookInput struct {
} `json:"input"`
} `json:"event"`

ToolName string `json:"tool_name"`
ToolName string `json:"tool_name"`
ToolInput *struct {
Command string `json:"command"`
} `json:"tool_input"`
Expand All @@ -81,8 +81,8 @@ type hookInput struct {
// a flat {"allow_tool": false, "deny_reason": "..."} object (not nested
// under hookSpecificOutput like Codex/Claude).
type antigravityHookOutput struct {
AllowTool bool `json:"allow_tool"`
DenyReason string `json:"deny_reason"`
AllowTool bool `json:"allow_tool"`
DenyReason string `json:"deny_reason"`
}

// codexHookOutput represents the JSON structure Codex expects as output
Expand Down Expand Up @@ -176,12 +176,15 @@ func (c HookCheckCommand) runStdinMode(stdin io.Reader, stdout io.Writer) error

result := gitcmd.Classify(command)

// Only emit output for git commands (Decision == "ask").
if result.Decision != "ask" {
// Emit output for any non-allow decision. "deny" (covered git subcommand)
// and "ask" (unknown git subcommand — safe default) both emit a deny so the
// agent is blocked from running raw git. Only pure "allow" (non-git or
// maintenance) exits cleanly with no output.
if result.Decision == "allow" {
return nil
}

reason := fmt.Sprintf("Use git-courer/%s instead of bash %s", result.MCPTool, command)
reason := result.Reason

switch agent {
case agentCodex, agentClaude:
Expand Down Expand Up @@ -298,4 +301,3 @@ func (c PreInvocationHookCommand) Run(args []string) error {

return json.NewEncoder(stdout).Encode(output)
}

8 changes: 4 additions & 4 deletions internal/delivery/cli/hook_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ func TestHookCheckRun_GitCommand(t *testing.T) {
if result["Command"] != "git status" {
t.Errorf("Command: got %q, want %q", result["Command"], "git status")
}
if result["Decision"] != "ask" {
t.Errorf("Decision: got %q, want %q", result["Decision"], "ask")
if result["Decision"] != "deny" {
t.Errorf("Decision: got %q, want %q", result["Decision"], "deny")
}
if result["MCPTool"] != "status" {
t.Errorf("MCPTool: got %q, want %q", result["MCPTool"], "status")
Expand Down Expand Up @@ -482,7 +482,7 @@ func TestHookCheckRun_GitCommand_OldStdout(t *testing.T) {
if result["Command"] != "git status" {
t.Errorf("Command: got %q, want %q", result["Command"], "git status")
}
if result["Decision"] != "ask" {
t.Errorf("Decision: got %q, want %q", result["Decision"], "ask")
if result["Decision"] != "deny" {
t.Errorf("Decision: got %q, want %q", result["Decision"], "deny")
}
}
8 changes: 8 additions & 0 deletions internal/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ func RunUninstall() error {
fmt.Fprintf(os.Stderr, " ⚠ Failed to strip OpenCode policy: %v\n", err)
}
}
// Strip git-courer @pi-lab/permissions rules from the Pi agent
// settings.json. Called AFTER restoreBackup for the same reason as
// opencode above. Only applies to the pi client.
if client.Name == "pi" {
if err := removePiPermissions(piPermissionsPath()); err != nil {
fmt.Fprintf(os.Stderr, " ⚠ Failed to strip Pi permissions: %v\n", err)
}
}
}

// Remove binary
Expand Down
Loading
Loading