tinyrelayfile
tinyrelay/internal/community/agent_grant_requests.go/

internal/community/agent_grant_requests.go raw
1package community
2
3// Agent grant requests are deliberately ordinary NIP-22 comments. The
4// request describes a narrow change to the current grant; the operator's
5// signed 30392 replacement remains the authority.
6
7import (
8 "context"
9 "database/sql"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "reflect"
15 "slices"
16 "strings"
17 "unicode/utf8"
18
19 "github.com/FelineStateMachine/tinyrelay/internal/event"
20)
21
22const agentGrantReasonMax = 500
23
24type grantRequestReader interface {
25 QueryRowContext(context.Context, string, ...any) *sql.Row
26 QueryContext(context.Context, string, ...any) (*sql.Rows, error)
27}
28
29func grantRequestOpen(ctx context.Context, db grantRequestReader, request event.Event, now int64) error {
30 if expires := event.Expiration(request); expires != 0 && expires <= now {
31 return errors.New("conflict: grant request has expired")
32 }
33 rows, err := db.QueryContext(ctx, `SELECT json_extract(raw,'$.content') FROM events AS reaction
34 WHERE kind=7 AND pubkey=? AND (expires=0 OR expires>?) AND EXISTS(
35 SELECT 1 FROM tags WHERE event_id=reaction.id AND name='e' AND value=?)`, event.Tag(request, "p"), now, request.ID)
36 if err != nil {
37 return err
38 }
39 defer rows.Close()
40 for rows.Next() {
41 var content string
42 if err := rows.Scan(&content); err != nil {
43 return err
44 }
45 if strings.TrimSpace(content) == "-" {
46 return errors.New("conflict: grant request was denied")
47 }
48 }
49 return rows.Err()
50}
51
52type AgentGrantChanges struct {
53 Kinds []int `json:"kinds,omitempty"`
54 Rooms []string `json:"rooms,omitempty"`
55 Repos []AgentRepo `json:"repos,omitempty"`
56 Sites []AgentSite `json:"sites,omitempty"`
57 Wiki *string `json:"wiki,omitempty"`
58 Jobs *string `json:"jobs,omitempty"`
59 Rate *int `json:"rate,omitempty"`
60}
61
62type AgentGrantRequest struct {
63 Base string `json:"base"`
64 Changes AgentGrantChanges `json:"changes"`
65}
66
67type AgentGrantReview struct {
68 RequestID string `json:"request_id"`
69 Base string `json:"base"`
70 Agent string `json:"agent"`
71 Operator string `json:"operator"`
72 Before AgentGrant `json:"before"`
73 After AgentGrant `json:"after"`
74 Unsigned event.Event `json:"unsigned"`
75}
76
77func IsAgentGrantRequest(e event.Event) bool {
78 return e.Kind == 1111 && event.Tag(e, "request") == "grant"
79}
80
81func decodeGrantRequest(e event.Event) (AgentGrantRequest, error) {
82 if !IsAgentGrantRequest(e) {
83 return AgentGrantRequest{}, errors.New("invalid: not an agent grant request")
84 }
85 if strings.TrimSpace(e.Content) == "" || len([]rune(e.Content)) > agentGrantReasonMax {
86 return AgentGrantRequest{}, fmt.Errorf("invalid: grant request rationale must be 1 to %d characters", agentGrantReasonMax)
87 }
88 if !utf8.ValidString(e.Content) {
89 return AgentGrantRequest{}, errors.New("invalid: grant request rationale is not valid UTF-8")
90 }
91 var req AgentGrantRequest
92 dec := json.NewDecoder(strings.NewReader(event.Tag(e, "grant")))
93 dec.DisallowUnknownFields()
94 if err := dec.Decode(&req); err != nil || req.Base == "" {
95 return AgentGrantRequest{}, errors.New("invalid: grant request needs a base and changes")
96 }
97 var extra any
98 if err := dec.Decode(&extra); err != io.EOF {
99 return AgentGrantRequest{}, errors.New("invalid: grant request JSON has trailing data")
100 }
101 allowed := map[string]bool{"request": true, "grant": true, "E": true, "K": true, "P": true, "e": true, "k": true, "p": true, "subject": true, "expiration": true}
102 seen := map[string]bool{}
103 for _, tag := range e.Tags {
104 if len(tag) == 0 || !allowed[tag[0]] {
105 return AgentGrantRequest{}, errors.New("invalid: grant request contains an unsupported tag")
106 }
107 if seen[tag[0]] {
108 return AgentGrantRequest{}, errors.New("invalid: grant request contains duplicate tags")
109 }
110 seen[tag[0]] = true
111 }
112 if req.Changes.Kinds == nil && req.Changes.Rooms == nil && req.Changes.Repos == nil && req.Changes.Sites == nil && req.Changes.Wiki == nil && req.Changes.Jobs == nil && req.Changes.Rate == nil {
113 return AgentGrantRequest{}, errors.New("invalid: grant request has no changes")
114 }
115 if err := validateGrantChanges(req.Changes); err != nil {
116 return AgentGrantRequest{}, err
117 }
118 return req, nil
119}
120
121func validateGrantChanges(changes AgentGrantChanges) error {
122 for _, room := range changes.Rooms {
123 if !ValidRoomID(room) {
124 return errors.New("invalid: grant request room")
125 }
126 }
127 repos, sites := map[string]bool{}, map[string]bool{}
128 for _, repo := range changes.Repos {
129 key := repo.Owner + ":" + repo.Identifier
130 if repos[key] {
131 return errors.New("invalid: repeated repository in grant request")
132 }
133 repos[key] = true
134 }
135 for _, site := range changes.Sites {
136 if sites[site.Label] || site.TTLDays < 0 {
137 return errors.New("invalid: duplicate site or negative TTL in grant request")
138 }
139 sites[site.Label] = true
140 }
141 return nil
142}
143
144func (s *Service) ReviewAgentGrantRequest(ctx context.Context, e event.Event, now int64) (AgentGrantReview, error) {
145 req, err := decodeGrantRequest(e)
146 if err != nil {
147 return AgentGrantReview{}, err
148 }
149 if err := grantRequestOpen(ctx, s.store.DB(), e, now); err != nil {
150 return AgentGrantReview{}, err
151 }
152 operator := event.Tag(e, "p")
153 if req.Base != event.Tag(e, "E") || req.Base != event.Tag(e, "e") {
154 return AgentGrantReview{}, errors.New("invalid: grant request base must match its NIP-22 references")
155 }
156 if len(event.TagValues(e, "p")) != 1 || operator == "" {
157 return AgentGrantReview{}, errors.New("invalid: grant request needs exactly one operator")
158 }
159 if len(event.Tag(e, "E")) != 64 || event.Tag(e, "e") != event.Tag(e, "E") || event.Tag(e, "K") != "30392" || event.Tag(e, "k") != "30392" || event.Tag(e, "P") != operator || event.Tag(e, "p") != operator {
160 return AgentGrantReview{}, errors.New("invalid: grant request needs matching NIP-22 grant references")
161 }
162 var raw string
163 var currentID string
164 var paused int
165 var revokedAt int64
166 if err := s.store.DB().QueryRowContext(ctx, `SELECT event_id,paused,revoked_at FROM agent_grants WHERE agent=?`, e.PubKey).Scan(&currentID, &paused, &revokedAt); err != nil || currentID != req.Base {
167 return AgentGrantReview{}, errors.New("conflict: grant request is no longer for the current grant")
168 }
169 if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, req.Base, event.KIND_AGENT_GRANT).Scan(&raw); err != nil {
170 if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, req.Base).Scan(&raw); err != nil {
171 return AgentGrantReview{}, errors.New("invalid: current agent grant not found")
172 }
173 }
174 var beforeEvent event.Event
175 if beforeEvent, err = event.Parse([]byte(raw)); err != nil {
176 return AgentGrantReview{}, err
177 }
178 review, err := s.reviewAgainstGrant(e, req, beforeEvent, now)
179 if err != nil {
180 return AgentGrantReview{}, err
181 }
182 review.Before.Paused = paused != 0
183 review.Before.RevokedAt = revokedAt
184 if !review.Before.Active(now) {
185 return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")
186 }
187 return review, nil
188}
189
190func (s *Service) reviewAgainstGrant(e event.Event, req AgentGrantRequest, beforeEvent event.Event, now int64) (AgentGrantReview, error) {
191 operator := event.Tag(e, "p")
192 before, err := ParseAgentGrant(beforeEvent, now)
193 if err != nil {
194 return AgentGrantReview{}, err
195 }
196 if before.Agent != e.PubKey || before.Owner != operator || !before.Active(now) {
197 return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")
198 }
199 afterEvent := beforeEvent
200 afterEvent.Tags = grantRequestTags(beforeEvent.Tags, req.Changes)
201 afterEvent.Tags = append(afterEvent.Tags, []string{"grant-request", e.ID}, []string{"grant-base", req.Base}, []string{"e", e.ID, "", "grant-request"})
202 afterEvent.ID, afterEvent.Sig, afterEvent.CreatedAt = "", "", now
203 if afterEvent.CreatedAt <= beforeEvent.CreatedAt {
204 afterEvent.CreatedAt = beforeEvent.CreatedAt + 1
205 }
206 after, err := ParseAgentGrant(afterEvent, now)
207 if err != nil {
208 return AgentGrantReview{}, err
209 }
210 if reflect.DeepEqual(after.Scope, before.Scope) {
211 return AgentGrantReview{}, errors.New("invalid: grant request changes nothing")
212 }
213 return AgentGrantReview{RequestID: e.ID, Base: req.Base, Agent: before.Agent, Operator: operator, Before: before, After: after, Unsigned: afterEvent}, nil
214}
215
216func (s *Service) ValidateAgentGrantReplacement(ctx context.Context, e event.Event, now int64) error {
217 return s.validateGrantReplacement(ctx, s.store.DB(), e, now)
218}
219
220func (s *Service) validateGrantReplacement(ctx context.Context, tx grantRequestReader, e event.Event, now int64) error {
221 requestID := event.Tag(e, "grant-request")
222 baseID := event.Tag(e, "grant-base")
223 if requestID == "" || baseID == "" || !slices.Contains(event.TagValues(e, "e"), requestID) {
224 return errors.New("invalid: grant replacement needs grant request provenance")
225 }
226 var raw string
227 if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=1111`, requestID).Scan(&raw); err != nil {
228 return errors.New("invalid: grant request is not stored")
229 }
230 request, err := event.Parse([]byte(raw))
231 if err != nil {
232 return err
233 }
234 if err := grantRequestOpen(ctx, tx, request, now); err != nil {
235 return err
236 }
237 var grantRaw string
238 if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, baseID, event.KIND_AGENT_GRANT).Scan(&grantRaw); err != nil {
239 if err := tx.QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, baseID).Scan(&grantRaw); err != nil {
240 return errors.New("invalid: current agent grant not found")
241 }
242 }
243 grantEvent, err := event.Parse([]byte(grantRaw))
244 if err != nil {
245 return err
246 }
247 req, err := decodeGrantRequest(request)
248 if err != nil {
249 return err
250 }
251 review, err := s.reviewAgainstGrant(request, req, grantEvent, now)
252 if err != nil || review.Base != baseID || review.Operator != e.PubKey || review.Agent != event.Tag(e, "d") {
253 return errors.New("restricted: grant replacement does not match the reviewed request")
254 }
255 var paused int
256 var revokedAt int64
257 if err := tx.QueryRowContext(ctx, `SELECT paused,revoked_at FROM agent_grants WHERE agent=?`, review.Agent).Scan(&paused, &revokedAt); err != nil {
258 return errors.New("conflict: agent grant is no longer current")
259 }
260 if paused != 0 || revokedAt != 0 || review.After.ExpiresAt <= now {
261 return errors.New("restricted: grant request is no longer active")
262 }
263 got, err := ParseAgentGrant(e, now)
264 if err != nil {
265 return err
266 }
267 if !reflect.DeepEqual(got.Scope, review.After.Scope) || got.ExpiresAt != review.After.ExpiresAt || got.Agent != review.After.Agent || got.Owner != review.After.Owner || got.Name != review.After.Name || e.Content != grantEvent.Content {
268 return errors.New("invalid: grant replacement scope differs from the reviewed request")
269 }
270 if !reflect.DeepEqual(e.Tags, review.Unsigned.Tags) {
271 return errors.New("invalid: grant replacement tags differ from the reviewed request")
272 }
273 var current string
274 if err := tx.QueryRowContext(ctx, `SELECT event_id FROM agent_grants WHERE agent=?`, got.Agent).Scan(&current); err != nil || current != baseID {
275 return errors.New("conflict: agent grant changed since the request")
276 }
277 return nil
278}
279
280func grantRequestTags(tags [][]string, changes AgentGrantChanges) [][]string {
281 replaceRepo := map[string]bool{}
282 for _, repo := range changes.Repos {
283 replaceRepo[repo.Owner+":"+repo.Identifier] = true
284 }
285 replaceSite := map[string]bool{}
286 for _, site := range changes.Sites {
287 replaceSite[site.Label] = true
288 }
289 copyTags := make([][]string, 0, len(tags)+len(changes.Kinds)+len(changes.Rooms))
290 seenKinds := map[string]bool{}
291 seenRooms := map[string]bool{}
292 remove := map[string]bool{}
293 if changes.Wiki != nil {
294 remove["wiki"] = true
295 }
296 if changes.Jobs != nil {
297 remove["jobs"] = true
298 }
299 if changes.Rate != nil {
300 remove["rate"] = true
301 }
302 for _, tag := range tags {
303 skip := false
304 if len(tag) > 1 && tag[0] == "repo" {
305 parts := strings.SplitN(tag[1], ":", 3)
306 skip = len(parts) == 3 && replaceRepo[parts[0]+":"+parts[1]]
307 }
308 if len(tag) > 1 && tag[0] == "sites" {
309 skip = replaceSite[tag[1]]
310 }
311 if len(tag) > 0 && !remove[tag[0]] && !skip && tag[0] != "grant-request" && tag[0] != "grant-base" && !(tag[0] == "e" && len(tag) > 3 && tag[3] == "grant-request") {
312 copyTags = append(copyTags, append([]string(nil), tag...))
313 if len(tag) > 1 && tag[0] == "k" {
314 seenKinds[tag[1]] = true
315 }
316 if len(tag) > 1 && tag[0] == "room" {
317 seenRooms[tag[1]] = true
318 }
319 }
320 }
321 // Changes are patches: retain the old entries and add or replace only the
322 // requested keys. Reconstructing these fields also gives deterministic
323 // output when a request repeats an existing value.
324 for _, k := range changes.Kinds {
325 value := fmt.Sprint(k)
326 if !seenKinds[value] {
327 copyTags = append(copyTags, []string{"k", value})
328 seenKinds[value] = true
329 }
330 }
331 for _, room := range changes.Rooms {
332 if !seenRooms[room] {
333 copyTags = append(copyTags, []string{"room", room})
334 seenRooms[room] = true
335 }
336 }
337 for _, repo := range changes.Repos {
338 copyTags = append(copyTags, []string{"repo", repo.Owner + ":" + repo.Identifier + ":" + repo.Level})
339 }
340 for _, site := range changes.Sites {
341 tag := []string{"sites", site.Label}
342 if site.TTLDays > 0 {
343 tag = append(tag, fmt.Sprintf("ttl=%d", site.TTLDays))
344 }
345 if site.Encrypted {
346 tag = append(tag, "encrypted")
347 }
348 copyTags = append(copyTags, tag)
349 }
350 if changes.Wiki != nil {
351 copyTags = append(copyTags, []string{"wiki", *changes.Wiki})
352 }
353 if changes.Jobs != nil {
354 copyTags = append(copyTags, []string{"jobs", *changes.Jobs})
355 }
356 if changes.Rate != nil {
357 copyTags = append(copyTags, []string{"rate", fmt.Sprint(*changes.Rate)})
358 }
359 return copyTags
360}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs