From a25dab6ee5b4293bb361d4dfc49f6e2e56ddf425 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Tue, 19 May 2026 14:22:12 +0200 Subject: [PATCH] 9p: permission model for session files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session files now have owner=session_id, group=agent. Permission checking is enforced on open for session files only: prompt/fifo.in: mode 0022 (group+other write, owner cannot) ctl: mode 0222 (everyone can write) Self-prompt rejection is now a natural consequence of the permission model — the session (owner) has no write bit on its own prompt file. Cross-agent prompting works via group 'agent' write bit. User access works via 'other' write bit. On attach, sessions are added to group 'agent', other users to 'user'. Removed the explicit self-prompt check from clunk. --- server.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/server.go b/server.go index abb43e3..44545fe 100644 --- a/server.go +++ b/server.go @@ -325,6 +325,66 @@ func (s *Server) InGroup(group, user string) bool { return s.groups[group][user] } +// fileOwnerGroup returns the uid and gid for a given path. +// Session files are owned by the session ID with group "agent". +// Everything else is owned by "ollie" with group "ollie". +func (s *Server) fileOwnerGroup(path string) (uid, gid string) { + if strings.HasPrefix(path, "/s/") { + parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2) + if len(parts) >= 1 && parts[0] != "new" { + // Check if parts[0] is a session (not a script like sh, bfg, etc.) + if sess := s.sessionStore.Session(parts[0]); sess != nil { + return parts[0], "agent" + } + } + } + return "ollie", "ollie" +} + +// checkPerm verifies that uname has the requested access (mode) to path. +// mode is the 9P open mode: OREAD=0, OWRITE=1, ORDWR=2, OEXEC=3. +// Only enforced on session files (/s/{session_id}/...). +func (s *Server) checkPerm(uname, path string, mode uint8) error { + uid, gid := s.fileOwnerGroup(path) + if uid == "ollie" { + return nil // non-session files: no enforcement + } + dir := s.makeStat(path) + perm := uint32(dir.Mode) & 0777 + + // Determine which permission bits apply. + var bits uint32 + if uname == uid { + bits = (perm >> 6) & 7 + } else if s.InGroup(gid, uname) { + bits = (perm >> 3) & 7 + } else { + bits = perm & 7 + } + + // Map 9P open mode to required permission bit. + omode := mode & 3 + switch omode { + case plan9.OREAD: + if bits&4 == 0 { + return fmt.Errorf("permission denied") + } + case plan9.OWRITE: + if bits&2 == 0 { + return fmt.Errorf("permission denied") + } + case plan9.ORDWR: + if bits&6 != 6 { + return fmt.Errorf("permission denied") + } + case plan9.OEXEC: + if bits&1 == 0 { + return fmt.Errorf("permission denied") + } + } + return nil +} + // defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default. func defaultTranscriptDir() string { @@ -711,6 +771,14 @@ func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall { qid := plan9.Qid{Type: QTDir, Path: 0} cs.fids[fc.Fid] = &fid{path: "/", qid: qid} s.log.Debug("Tattach uname=%q", fc.Uname) + // Assign group membership based on whether uname is a session ID. + if fc.Uname != "" { + if sess := s.sessionStore.Session(fc.Uname); sess != nil { + s.AddGroup("agent", fc.Uname) + } else { + s.AddGroup("user", fc.Uname) + } + } return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} } @@ -778,6 +846,10 @@ func (s *Server) open(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return errFcall(fc, "bad fid") } + if err := s.checkPerm(cs.uname, f.path, fc.Mode); err != nil { + return errFcall(fc, err.Error()) + } + f.mode = fc.Mode s.log.Debug("Topen fid=%d path=%q mode=%d", fc.Fid, f.path, fc.Mode) return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid} @@ -1220,13 +1292,6 @@ func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if writable { s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname) input := strings.TrimSpace(string(data)) - // Reject self-prompt before async dispatch so the error reaches the client. - if uname != "" && strings.HasPrefix(path, "/s/") { - parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) - if len(parts) == 3 && parts[2] == "prompt" && uname == parts[1] { - return errFcall(fc, "self-prompt rejected") - } - } if s.isAsyncWrite(path) { go s.handleWrite(path, input, uname) //nolint:errcheck } else if err := s.handleWrite(path, input, uname); err != nil { @@ -1579,8 +1644,10 @@ func (s *Server) makeStat(path string) plan9.Dir { } } else { switch base { - case "ctl", "prompt", "fifo.in": - mode = 0200 + case "ctl": + mode = 0222 // owner+group+other write + case "prompt", "fifo.in": + mode = 0022 // group+other write; owner cannot write (no self-prompt) case "chat", "usage", "cost", "ctxsz", "models", "fifo.out", "offset", "statewait", "systemprompt": mode = 0444 case "cfg": @@ -1622,13 +1689,14 @@ func (s *Server) makeStat(path string) plan9.Dir { } } + uid, gid := s.fileOwnerGroup(path) dir := plan9.Dir{ Qid: qid, Mode: mode, Name: base, - Uid: "ollie", - Gid: "ollie", - Muid: "ollie", + Uid: uid, + Gid: gid, + Muid: uid, } // For chat and tailable mutable files, report actual size and