snapshot of c000b74a5b09c433f96a69fa102c5c8f6583dff0 Annotations about the code that implements koment.

internal/agentpolicy/hooks.go

1 package agentpolicy
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9 "strings"
10
11 "github.com/koment-dev/koment/internal/policy"
12 )
13
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 }
22
23 const toolHookApplyPatch = "apply_patch"
24 const toolHookOpencodeEdit = "opencode_edit"
25
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 }
66
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 }
74
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 }
88
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 }
99
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 }
125
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 }
135
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 }
143
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 }
171
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 }
185
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 }
203
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close