Skip to content

Commit c37dc78

Browse files
idoubiclaude
andcommitted
feat: hooks wiring, session persistence, enhanced permissions - v0.4.1
Hooks: - Config hooks (preToolUse/postToolUse) now wired into SDK agent - Shell command hooks execute via bash -c - /hooks command shows configured hooks Session Management: - Conversation blocks saved to <session>-conv.json after each query - --resume flag restores conversation history in TUI - /resume lists sessions with turn count and cost - Session metadata (model, turns, cost, title) auto-updated Permissions: - Pattern-based allow/deny rules in ~/.codeany/permissions.json - /permissions rules - show all configured rules - /permissions allow <tool> [pattern] - add allow rule - /permissions deny <tool> [pattern] - add deny rule - Permission callback checks deny rules before allow rules - Support wildcard patterns (git*, src/**) Total: 5900+ lines, 49 commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 337389d commit c37dc78

4 files changed

Lines changed: 296 additions & 6 deletions

File tree

internal/config/permissions.go

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@ package config
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"path/filepath"
8+
"strings"
79
"sync"
810
)
911

1012
// PermissionRules stores persistent permission rules
1113
type PermissionRules struct {
12-
AlwaysAllow map[string]bool `json:"alwaysAllow"` // tool name -> always allow
14+
AlwaysAllow map[string]bool `json:"alwaysAllow"` // tool name -> always allow
15+
AllowRules []PermRule `json:"allowRules,omitempty"` // pattern-based allow rules
16+
DenyRules []PermRule `json:"denyRules,omitempty"` // pattern-based deny rules
1317
mu sync.RWMutex
1418
path string
1519
}
1620

21+
// PermRule is a pattern-based permission rule
22+
type PermRule struct {
23+
Tool string `json:"tool"` // tool name (e.g., "Bash", "Edit", "*")
24+
Pattern string `json:"pattern,omitempty"` // pattern to match against input (e.g., "git *", "src/**")
25+
}
26+
1727
// LoadPermissionRules loads rules from disk
1828
func LoadPermissionRules() *PermissionRules {
1929
path := filepath.Join(GlobalConfigDir(), "permissions.json")
@@ -34,13 +44,40 @@ func LoadPermissionRules() *PermissionRules {
3444
return pr
3545
}
3646

37-
// IsAllowed checks if a tool is always allowed
47+
// IsAllowed checks if a tool call is allowed by rules
3848
func (pr *PermissionRules) IsAllowed(toolName string) bool {
3949
pr.mu.RLock()
4050
defer pr.mu.RUnlock()
4151
return pr.AlwaysAllow[toolName]
4252
}
4353

54+
// IsAllowedWithInput checks allow/deny rules with input pattern matching
55+
func (pr *PermissionRules) IsAllowedWithInput(toolName string, input map[string]interface{}) (allowed bool, denied bool) {
56+
pr.mu.RLock()
57+
defer pr.mu.RUnlock()
58+
59+
// Check deny rules first
60+
for _, rule := range pr.DenyRules {
61+
if matchRule(rule, toolName, input) {
62+
return false, true
63+
}
64+
}
65+
66+
// Check always allow
67+
if pr.AlwaysAllow[toolName] {
68+
return true, false
69+
}
70+
71+
// Check allow rules
72+
for _, rule := range pr.AllowRules {
73+
if matchRule(rule, toolName, input) {
74+
return true, false
75+
}
76+
}
77+
78+
return false, false
79+
}
80+
4481
// SetAlwaysAllow marks a tool as always allowed and saves to disk
4582
func (pr *PermissionRules) SetAlwaysAllow(toolName string) {
4683
pr.mu.Lock()
@@ -49,6 +86,75 @@ func (pr *PermissionRules) SetAlwaysAllow(toolName string) {
4986
pr.save()
5087
}
5188

89+
// AddAllowRule adds a pattern-based allow rule
90+
func (pr *PermissionRules) AddAllowRule(tool, pattern string) {
91+
pr.mu.Lock()
92+
pr.AllowRules = append(pr.AllowRules, PermRule{Tool: tool, Pattern: pattern})
93+
pr.mu.Unlock()
94+
pr.save()
95+
}
96+
97+
// AddDenyRule adds a pattern-based deny rule
98+
func (pr *PermissionRules) AddDenyRule(tool, pattern string) {
99+
pr.mu.Lock()
100+
pr.DenyRules = append(pr.DenyRules, PermRule{Tool: tool, Pattern: pattern})
101+
pr.mu.Unlock()
102+
pr.save()
103+
}
104+
105+
// RemoveAlwaysAllow removes an always-allow entry
106+
func (pr *PermissionRules) RemoveAlwaysAllow(toolName string) {
107+
pr.mu.Lock()
108+
delete(pr.AlwaysAllow, toolName)
109+
pr.mu.Unlock()
110+
pr.save()
111+
}
112+
113+
// FormatRules returns a readable summary
114+
func (pr *PermissionRules) FormatRules() string {
115+
pr.mu.RLock()
116+
defer pr.mu.RUnlock()
117+
118+
var b strings.Builder
119+
b.WriteString("Permission rules:\n\n")
120+
121+
if len(pr.AlwaysAllow) > 0 {
122+
b.WriteString(" Always allow:\n")
123+
for tool := range pr.AlwaysAllow {
124+
b.WriteString(fmt.Sprintf(" ✓ %s\n", tool))
125+
}
126+
}
127+
128+
if len(pr.AllowRules) > 0 {
129+
b.WriteString(" Allow rules:\n")
130+
for _, r := range pr.AllowRules {
131+
p := r.Pattern
132+
if p == "" {
133+
p = "*"
134+
}
135+
b.WriteString(fmt.Sprintf(" ✓ %s (%s)\n", r.Tool, p))
136+
}
137+
}
138+
139+
if len(pr.DenyRules) > 0 {
140+
b.WriteString(" Deny rules:\n")
141+
for _, r := range pr.DenyRules {
142+
p := r.Pattern
143+
if p == "" {
144+
p = "*"
145+
}
146+
b.WriteString(fmt.Sprintf(" ✗ %s (%s)\n", r.Tool, p))
147+
}
148+
}
149+
150+
if len(pr.AlwaysAllow) == 0 && len(pr.AllowRules) == 0 && len(pr.DenyRules) == 0 {
151+
b.WriteString(" (no rules configured)\n")
152+
}
153+
154+
b.WriteString(fmt.Sprintf("\n Stored in: %s", pr.path))
155+
return b.String()
156+
}
157+
52158
// save writes rules to disk
53159
func (pr *PermissionRules) save() {
54160
pr.mu.RLock()
@@ -61,3 +167,41 @@ func (pr *PermissionRules) save() {
61167
os.MkdirAll(filepath.Dir(pr.path), 0755)
62168
os.WriteFile(pr.path, data, 0644)
63169
}
170+
171+
// matchRule checks if a rule matches a tool call
172+
func matchRule(rule PermRule, toolName string, input map[string]interface{}) bool {
173+
if rule.Tool != "*" && rule.Tool != toolName {
174+
return false
175+
}
176+
if rule.Pattern == "" {
177+
return true
178+
}
179+
// Match pattern against relevant input field
180+
var value string
181+
switch toolName {
182+
case "Bash":
183+
value, _ = input["command"].(string)
184+
case "Edit", "Write", "Read":
185+
value, _ = input["file_path"].(string)
186+
case "Glob":
187+
value, _ = input["pattern"].(string)
188+
case "Grep":
189+
value, _ = input["pattern"].(string)
190+
default:
191+
return true
192+
}
193+
return simpleMatch(rule.Pattern, value)
194+
}
195+
196+
func simpleMatch(pattern, value string) bool {
197+
if pattern == "*" {
198+
return true
199+
}
200+
if strings.HasSuffix(pattern, "*") {
201+
return strings.HasPrefix(value, strings.TrimSuffix(pattern, "*"))
202+
}
203+
if strings.HasPrefix(pattern, "*") {
204+
return strings.HasSuffix(value, strings.TrimPrefix(pattern, "*"))
205+
}
206+
return pattern == value
207+
}

internal/session/session.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ func FormatSessionList(sessions []SessionSummary) string {
172172
return b.String()
173173
}
174174

175+
// ConversationEntry stores one message in the conversation log
176+
type ConversationEntry struct {
177+
Role string `json:"role"` // "user", "assistant", "system", "tool"
178+
Content string `json:"content,omitempty"`
179+
ToolName string `json:"toolName,omitempty"`
180+
ToolInput string `json:"toolInput,omitempty"`
181+
Timestamp int64 `json:"ts"`
182+
}
183+
175184
// UpdateMeta updates session metadata
176185
func (s *Session) UpdateMeta(model string, turns int, cost float64, title string) {
177186
s.Model = model
@@ -183,6 +192,34 @@ func (s *Session) UpdateMeta(model string, turns int, cost float64, title string
183192
s.Save()
184193
}
185194

195+
// SaveConversation saves the conversation log alongside the session
196+
func (s *Session) SaveConversation(entries []ConversationEntry) error {
197+
if s.path == "" {
198+
return nil
199+
}
200+
convPath := strings.TrimSuffix(s.path, ".json") + "-conv.json"
201+
data, err := json.MarshalIndent(entries, "", " ")
202+
if err != nil {
203+
return err
204+
}
205+
return os.WriteFile(convPath, data, 0644)
206+
}
207+
208+
// LoadConversation loads the conversation log for this session
209+
func (s *Session) LoadConversation() []ConversationEntry {
210+
if s.path == "" {
211+
return nil
212+
}
213+
convPath := strings.TrimSuffix(s.path, ".json") + "-conv.json"
214+
data, err := os.ReadFile(convPath)
215+
if err != nil {
216+
return nil
217+
}
218+
var entries []ConversationEntry
219+
json.Unmarshal(data, &entries)
220+
return entries
221+
}
222+
186223
// Save persists the session to disk
187224
func (s *Session) Save() error {
188225
if s.path == "" {

internal/slash/slash.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,32 @@ func (h *Handler) permissions(args []string) Result {
425425
indicator = " 🔒 Will ask for write operations"
426426
}
427427
return Result{Message: fmt.Sprintf("Permission mode: %s%s", mode, indicator)}
428+
case "rules":
429+
return Result{Message: config.LoadPermissionRules().FormatRules()}
430+
case "allow":
431+
if len(args) < 2 {
432+
return Result{Message: "Usage: /permissions allow <tool> [pattern]\nExample: /permissions allow Bash git*"}
433+
}
434+
tool := args[1]
435+
pattern := ""
436+
if len(args) > 2 {
437+
pattern = strings.Join(args[2:], " ")
438+
}
439+
config.LoadPermissionRules().AddAllowRule(tool, pattern)
440+
return Result{Message: fmt.Sprintf("✓ Allow rule added: %s %s", tool, pattern)}
441+
case "deny":
442+
if len(args) < 2 {
443+
return Result{Message: "Usage: /permissions deny <tool> [pattern]\nExample: /permissions deny Bash rm*"}
444+
}
445+
tool := args[1]
446+
pattern := ""
447+
if len(args) > 2 {
448+
pattern = strings.Join(args[2:], " ")
449+
}
450+
config.LoadPermissionRules().AddDenyRule(tool, pattern)
451+
return Result{Message: fmt.Sprintf("✓ Deny rule added: %s %s", tool, pattern)}
428452
default:
429-
return Result{Message: fmt.Sprintf("Unknown mode: %s\nUse: bypass, auto, default, plan", mode)}
453+
return Result{Message: fmt.Sprintf("Unknown: %s\nUse: bypass, auto, default, plan, rules, allow <tool>, deny <tool>", mode)}
430454
}
431455
}
432456

0 commit comments

Comments
 (0)