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