internal/agentpolicy/hooks.go
1
package agentpolicy
3
import (
4
"encoding/json"
5
"fmt"
6
"path/filepath"
7
"regexp"
8
"strings"
9
)
11
type toolHookInput struct {
12
ToolName string `json:"tool_name"`
13
ToolInput struct {
14
Command string `json:"command"`
15
FilePath string `json:"filePath"`
16
Content string `json:"content"`
17
} `json:"tool_input"`
18
}
20
const toolHookApplyPatch = "apply_patch"
21
const toolHookOpencodeEdit = "opencode_edit"
23
// PreToolOutput blocks a Go patch that adds ordinary comment intent.
24
func PreToolOutput(input []byte) ([]byte, error) {
25
var request toolHookInput
26
if err := json.Unmarshal(input, &request); err != nil {
27
return nil, fmt.Errorf("parsing PreToolUse input: %w", err)
28
}
29
var body string
30
switch request.ToolName {
31
case toolHookApplyPatch:
32
body = request.ToolInput.Command
33
case toolHookOpencodeEdit:
34
body = syntheticPatchFromEdit(request.ToolInput.FilePath, request.ToolInput.Content)
35
default:
36
return []byte("{}\n"), nil
37
}
38
comments := addedCommentIntent(body)
39
if len(comments) == 0 {
40
return []byte("{}\n"), nil
41
}
42
reason := fmt.Sprintf("koment policy blocked ordinary comment intent (%s). Record the rationale against nearby code with koment_add instead. If a comment already exists, use koment_convert_comment; retaining one requires koment_acknowledge_comment with explicit acknowledgement.", strings.Join(comments, ", "))
43
response := map[string]any{
44
"hookSpecificOutput": map[string]any{
45
"hookEventName": "PreToolUse",
46
"permissionDecision": "deny",
47
"permissionDecisionReason": reason,
48
},
49
}
50
encoded, err := json.Marshal(response)
51
if err != nil {
52
return nil, fmt.Errorf("encoding PreToolUse output: %w", err)
53
}
54
return append(encoded, '\n'), nil
55
}
57
func syntheticPatchFromEdit(filePath, content string) string {
58
var builder strings.Builder
59
builder.WriteString("*** Update File: ")
60
builder.WriteString(filepath.ToSlash(filePath))
61
builder.WriteByte('\n')
62
builder.WriteString("@@\n")
63
for _, line := range strings.Split(content, "\n") {
64
builder.WriteByte('+')
65
builder.WriteString(line)
66
builder.WriteByte('\n')
67
}
68
return builder.String()
69
}
71
// StopWasContinued reports whether the Stop hook already continued this turn.
72
func StopWasContinued(input []byte) (bool, error) {
73
var request struct {
74
StopHookActive bool `json:"stop_hook_active"`
75
}
76
if err := json.Unmarshal(input, &request); err != nil {
77
return false, fmt.Errorf("parsing Stop input: %w", err)
78
}
79
return request.StopHookActive, nil
80
}
82
func addedCommentIntent(patch string) []string {
83
lines := strings.Split(patch, "\n")
84
file := ""
85
var found []string
86
for index := 0; index < len(lines); index++ {
87
line := lines[index]
88
if name, ok := patchFile(line); ok {
89
file = name
90
continue
91
}
92
if !strings.HasSuffix(file, ".go") || !isAddedComment(line) {
93
continue
94
}
95
start := index
96
for index+1 < len(lines) && isAddedComment(lines[index+1]) {
97
index++
98
}
99
group := lines[start : index+1]
100
if intrinsicPatchComment(group) || publicDocumentationPatch(group, followingCode(lines, index+1)) {
101
continue
102
}
103
found = append(found, fmt.Sprintf("%s: %s", file, strings.TrimSpace(strings.TrimPrefix(group[0], "+"))))
104
}
105
return found
106
}
108
func patchFile(line string) (string, bool) {
109
prefixes := []string{"*** Add File: ", "*** Update File: "}
110
for _, prefix := range prefixes {
111
if strings.HasPrefix(line, prefix) {
112
return filepath.ToSlash(strings.TrimSpace(strings.TrimPrefix(line, prefix))), true
113
}
114
}
115
return "", false
116
}
118
func isAddedComment(line string) bool {
119
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
120
return false
121
}
122
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "+"))
123
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*")
124
}
126
func intrinsicPatchComment(group []string) bool {
127
raw := strings.Join(group, "\n")
128
if strings.Contains(raw, "https://") || strings.Contains(raw, "http://") ||
129
strings.Contains(raw, "Deprecated:") ||
130
(strings.Contains(raw, "Code generated") && strings.Contains(raw, "DO NOT EDIT.")) {
131
return true
132
}
133
for _, line := range group {
134
text := strings.TrimSpace(strings.TrimPrefix(line, "+"))
135
text = strings.TrimSpace(strings.TrimPrefix(text, "//"))
136
allowed := false
137
for _, prefix := range []string{"go:", "+build", "line ", "nolint", "lint:", "revive:", "gosec", "export ", "#cgo"} {
138
if strings.HasPrefix(text, prefix) {
139
allowed = true
140
break
141
}
142
}
143
if !allowed {
144
return false
145
}
146
}
147
return true
148
}
150
func publicDocumentationPatch(group []string, code string) bool {
151
first := strings.TrimSpace(strings.TrimPrefix(group[0], "+"))
152
first = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(first, "//"), "/*"))
153
name := strings.Fields(first)
154
if len(name) == 0 {
155
return false
156
}
157
if name[0] == "Package" && strings.HasPrefix(strings.TrimSpace(code), "package ") {
158
return true
159
}
160
if name[0][0] < 'A' || name[0][0] > 'Z' {
161
return false
162
}
163
quoted := regexp.QuoteMeta(strings.Trim(name[0], "`'\".,:;()"))
164
declaration := regexp.MustCompile(`^(?:func\s+(?:\([^)]*\)\s+)?|type\s+|var\s+|const\s+)` + quoted + `\b`)
165
return declaration.MatchString(strings.TrimSpace(code))
166
}
168
func followingCode(lines []string, start int) string {
169
for _, line := range lines[start:] {
170
if strings.HasPrefix(line, "*** ") || strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "-") {
171
continue
172
}
173
candidate := line
174
if strings.HasPrefix(candidate, "+") || strings.HasPrefix(candidate, " ") {
175
candidate = candidate[1:]
176
}
177
trimmed := strings.TrimSpace(candidate)
178
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
179
continue
180
}
181
return candidate
182
}
183
return ""
184
}