internal/agentpolicy/hooks.go
1
package agentpolicy
3
import (
4
"encoding/json"
5
"fmt"
6
"os"
7
"path/filepath"
8
"regexp"
9
"strings"
11
"github.com/koment-dev/koment/internal/policy"
12
)
14
type toolHookInput struct {
15
ToolName string `json:"tool_name"`
16
ToolInput struct {
17
Command string `json:"command"`
18
FilePath string `json:"filePath"`
19
Content string `json:"content"`
20
} `json:"tool_input"`
21
}
23
const toolHookApplyPatch = "apply_patch"
24
const toolHookOpencodeEdit = "opencode_edit"
26
// PreToolOutput blocks a Go patch that adds ordinary comment intent.
27
func PreToolOutput(input []byte) ([]byte, error) {
28
activation, err := activePolicy()
29
if err != nil {
30
return nil, err
31
}
32
if activation == nil {
33
return []byte("{}\n"), nil
34
}
35
var request toolHookInput
36
if err := json.Unmarshal(input, &request); err != nil {
37
return nil, fmt.Errorf("parsing PreToolUse input: %w", err)
38
}
39
var body string
40
switch request.ToolName {
41
case toolHookApplyPatch:
42
body = request.ToolInput.Command
43
case toolHookOpencodeEdit:
44
body = syntheticPatchFromEdit(request.ToolInput.FilePath, request.ToolInput.Content)
45
default:
46
return []byte("{}\n"), nil
47
}
48
comments := addedCommentIntent(body, activation.Configured)
49
if len(comments) == 0 {
50
return []byte("{}\n"), nil
51
}
52
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, ", "))
53
response := map[string]any{
54
"hookSpecificOutput": map[string]any{
55
"hookEventName": "PreToolUse",
56
"permissionDecision": "deny",
57
"permissionDecisionReason": reason,
58
},
59
}
60
encoded, err := json.Marshal(response)
61
if err != nil {
62
return nil, fmt.Errorf("encoding PreToolUse output: %w", err)
63
}
64
return append(encoded, '\n'), nil
65
}
67
func activePolicy() (*policy.Activation, error) {
68
workingDirectory, err := os.Getwd()
69
if err != nil {
70
return nil, fmt.Errorf("finding the working directory: %w", err)
71
}
72
return policy.Detect(workingDirectory)
73
}
75
func syntheticPatchFromEdit(filePath, content string) string {
76
var builder strings.Builder
77
builder.WriteString("*** Update File: ")
78
builder.WriteString(filepath.ToSlash(filePath))
79
builder.WriteByte('\n')
80
builder.WriteString("@@\n")
81
for _, line := range strings.Split(content, "\n") {
82
builder.WriteByte('+')
83
builder.WriteString(line)
84
builder.WriteByte('\n')
85
}
86
return builder.String()
87
}
89
// StopWasContinued reports whether the Stop hook already continued this turn.
90
func StopWasContinued(input []byte) (bool, error) {
91
var request struct {
92
StopHookActive bool `json:"stop_hook_active"`
93
}
94
if err := json.Unmarshal(input, &request); err != nil {
95
return false, fmt.Errorf("parsing Stop input: %w", err)
96
}
97
return request.StopHookActive, nil
98
}
100
func addedCommentIntent(patch string, configured policy.Policy) []string {
101
lines := strings.Split(patch, "\n")
102
file := ""
103
var found []string
104
for index := 0; index < len(lines); index++ {
105
line := lines[index]
106
if name, ok := patchFile(line); ok {
107
file = name
108
continue
109
}
110
if !strings.HasSuffix(file, ".go") || !isAddedComment(line) {
111
continue
112
}
113
start := index
114
for index+1 < len(lines) && isAddedComment(lines[index+1]) {
115
index++
116
}
117
group := lines[start : index+1]
118
if intrinsicPatchComment(group, configured) || publicDocumentationPatch(group, followingCode(lines, index+1)) {
119
continue
120
}
121
found = append(found, fmt.Sprintf("%s: %s", file, strings.TrimSpace(strings.TrimPrefix(group[0], "+"))))
122
}
123
return found
124
}
126
func patchFile(line string) (string, bool) {
127
prefixes := []string{"*** Add File: ", "*** Update File: "}
128
for _, prefix := range prefixes {
129
if strings.HasPrefix(line, prefix) {
130
return filepath.ToSlash(strings.TrimSpace(strings.TrimPrefix(line, prefix))), true
131
}
132
}
133
return "", false
134
}
136
func isAddedComment(line string) bool {
137
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
138
return false
139
}
140
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "+"))
141
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*")
142
}
144
func intrinsicPatchComment(group []string, configured policy.Policy) bool {
145
raw := strings.Join(group, "\n")
146
if strings.Contains(raw, "https://") || strings.Contains(raw, "http://") ||
147
strings.Contains(raw, "Deprecated:") ||
148
(strings.Contains(raw, "Code generated") && strings.Contains(raw, "DO NOT EDIT.")) {
149
return true
150
}
151
body := patchCommentBody(group)
152
if configured.MatchesAllowedAnnotation(body) {
153
return true
154
}
155
for _, line := range group {
156
text := strings.TrimSpace(strings.TrimPrefix(line, "+"))
157
text = strings.TrimSpace(strings.TrimPrefix(text, "//"))
158
allowed := false
159
for _, prefix := range []string{"go:", "+build", "line ", "nolint", "lint:", "revive:", "gosec", "export ", "#cgo"} {
160
if strings.HasPrefix(text, prefix) {
161
allowed = true
162
break
163
}
164
}
165
if !allowed {
166
return false
167
}
168
}
169
return true
170
}
172
func patchCommentBody(group []string) string {
173
var cleaned []string
174
for _, line := range group {
175
text := strings.TrimPrefix(line, "+")
176
text = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(text, "/*"), "//"))
177
text = strings.TrimSpace(strings.TrimSuffix(text, "*/"))
178
if text == "" {
179
continue
180
}
181
cleaned = append(cleaned, text)
182
}
183
return strings.Join(cleaned, " ")
184
}
186
func publicDocumentationPatch(group []string, code string) bool {
187
first := strings.TrimSpace(strings.TrimPrefix(group[0], "+"))
188
first = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(first, "//"), "/*"))
189
name := strings.Fields(first)
190
if len(name) == 0 {
191
return false
192
}
193
if name[0] == "Package" && strings.HasPrefix(strings.TrimSpace(code), "package ") {
194
return true
195
}
196
if name[0][0] < 'A' || name[0][0] > 'Z' {
197
return false
198
}
199
quoted := regexp.QuoteMeta(strings.Trim(name[0], "`'\".,:;()"))
200
declaration := regexp.MustCompile(`^(?:func\s+(?:\([^)]*\)\s+)?|type\s+|var\s+|const\s+)` + quoted + `\b`)
201
return declaration.MatchString(strings.TrimSpace(code))
202
}
204
func followingCode(lines []string, start int) string {
205
for _, line := range lines[start:] {
206
if strings.HasPrefix(line, "*** ") || strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "-") {
207
continue
208
}
209
candidate := line
210
if strings.HasPrefix(candidate, "+") || strings.HasPrefix(candidate, " ") {
211
candidate = candidate[1:]
212
}
213
trimmed := strings.TrimSpace(candidate)
214
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
215
continue
216
}
217
return candidate
218
}
219
return ""
220
}